fix: merge conflicts from preview
This commit is contained in:
commit
3729011cb0
283 changed files with 4895 additions and 5157 deletions
|
|
@ -86,6 +86,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
|
|||
value={instanceAdmins[0]?.user_detail?.email ?? ""}
|
||||
placeholder="Admin email"
|
||||
className="w-full cursor-not-allowed !text-custom-text-400"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
|
|||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-2 min-w-[10rem] ${
|
||||
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
|
||||
isSidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
|
||||
ref={helpOptionsRef}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export const InstanceSetupForm: FC = (props) => {
|
|||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -190,6 +191,7 @@ export const InstanceSetupForm: FC = (props) => {
|
|||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,6 +210,7 @@ export const InstanceSetupForm: FC = (props) => {
|
|||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
|
|
@ -247,6 +250,7 @@ export const InstanceSetupForm: FC = (props) => {
|
|||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const InstanceSignInForm: FC = (props) => {
|
|||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -145,6 +146,7 @@ export const InstanceSignInForm: FC = (props) => {
|
|||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "^1.6.7",
|
||||
"axios": "^1.7.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.356.0",
|
||||
|
|
|
|||
|
|
@ -52,4 +52,4 @@ SPACE_BASE_URL=
|
|||
APP_BASE_URL=
|
||||
|
||||
# Hard delete files after days
|
||||
HARD_DELETE_AFTER_DAYS=
|
||||
HARD_DELETE_AFTER_DAYS=60
|
||||
|
|
|
|||
|
|
@ -32,4 +32,3 @@ python manage.py create_bucket
|
|||
python manage.py clear_cache
|
||||
|
||||
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||
|
||||
|
|
|
|||
|
|
@ -40,3 +40,44 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
|
|||
request.META["X-RateLimit-Reset"] = reset_time
|
||||
|
||||
return allowed
|
||||
|
||||
|
||||
class ServiceTokenRateThrottle(SimpleRateThrottle):
|
||||
scope = "service_token"
|
||||
rate = "300/minute"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
# Retrieve the API key from the request header
|
||||
api_key = request.headers.get("X-Api-Key")
|
||||
if not api_key:
|
||||
return None # Allow the request if there's no API key
|
||||
|
||||
# Use the API key as part of the cache key
|
||||
return f"{self.scope}:{api_key}"
|
||||
|
||||
def allow_request(self, request, view):
|
||||
allowed = super().allow_request(request, view)
|
||||
|
||||
if allowed:
|
||||
now = self.timer()
|
||||
# Calculate the remaining limit and reset time
|
||||
history = self.cache.get(self.key, [])
|
||||
|
||||
# Remove old histories
|
||||
while history and history[-1] <= now - self.duration:
|
||||
history.pop()
|
||||
|
||||
# Calculate the requests
|
||||
num_requests = len(history)
|
||||
|
||||
# Check available requests
|
||||
available = self.num_requests - num_requests
|
||||
|
||||
# Unix timestamp for when the rate limit will reset
|
||||
reset_time = int(now + self.duration)
|
||||
|
||||
# Add headers
|
||||
request.META["X-RateLimit-Remaining"] = max(0, available)
|
||||
request.META["X-RateLimit-Reset"] = reset_time
|
||||
|
||||
return allowed
|
||||
|
|
@ -269,6 +269,7 @@ class LabelSerializer(BaseSerializer):
|
|||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -430,4 +431,3 @@ class IssueExpandSerializer(BaseSerializer):
|
|||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class StateSerializer(BaseSerializer):
|
|||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|||
from django.db import IntegrityError
|
||||
from django.urls import resolve
|
||||
from django.utils import timezone
|
||||
from plane.db.models.api import APIToken
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -16,7 +17,7 @@ from rest_framework.views import APIView
|
|||
|
||||
# Module imports
|
||||
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.paginator import BasePaginator
|
||||
|
||||
|
|
@ -44,15 +45,29 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
|||
IsAuthenticated,
|
||||
]
|
||||
|
||||
throttle_classes = [
|
||||
ApiKeyRateThrottle,
|
||||
]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in list(self.filter_backends):
|
||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||
return queryset
|
||||
|
||||
def get_throttles(self):
|
||||
throttle_classes = []
|
||||
api_key = self.request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
service_token = APIToken.objects.filter(
|
||||
token=api_key,
|
||||
is_service=True,
|
||||
).first()
|
||||
|
||||
if service_token:
|
||||
throttle_classes.append(ServiceTokenRateThrottle())
|
||||
return throttle_classes
|
||||
|
||||
throttle_classes.append(ApiKeyRateThrottle())
|
||||
|
||||
return throttle_classes
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""
|
||||
Handle any exception that occurs, by returning an appropriate response,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from plane.api.serializers import (
|
|||
CycleSerializer,
|
||||
)
|
||||
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 (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
|
|
@ -672,17 +672,6 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
|||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so no new issues can be added"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all CycleIssues already created
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from rest_framework.response import Response
|
|||
# Module imports
|
||||
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from plane.app.permissions import (
|
|||
ProjectLitePermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
|
|
@ -355,6 +355,124 @@ class IssueAPIEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def put(self, request, slug, project_id):
|
||||
# Get the entities required for putting the issue, external_id and
|
||||
# external_source are must to identify the issue here
|
||||
project = Project.objects.get(pk=project_id)
|
||||
external_id = request.data.get("external_id")
|
||||
external_source = request.data.get("external_source")
|
||||
|
||||
# If the external_id and source are present, we need to find the exact
|
||||
# issue that needs to be updated with the provided external_id and
|
||||
# external_source
|
||||
if external_id and external_source:
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
)
|
||||
|
||||
# Get the current instance of the issue in order to track
|
||||
# changes and dispatch the issue activity
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
# Get the requested data, encode it as django object and pass it
|
||||
# to serializer to validation
|
||||
requested_data = json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
)
|
||||
serializer = IssueSerializer(
|
||||
issue,
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
},
|
||||
partial=True,
|
||||
)
|
||||
if serializer.is_valid():
|
||||
# If the serializer is valid, save the issue and dispatch
|
||||
# the update issue activity worker event.
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
# If the serializer is not valid, respond with 400 bad
|
||||
# request
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Issue.DoesNotExist:
|
||||
# If the issue does not exist, a new record needs to be created
|
||||
# for the requested data.
|
||||
# Serialize the data with the context of the project and
|
||||
# workspace
|
||||
serializer = IssueSerializer(
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
# If the serializer is valid, save the issue and dispatch the
|
||||
# issue activity worker event as created
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Refetch the issue
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=serializer.data["id"],
|
||||
).first()
|
||||
|
||||
# If any of the created_at or created_by is present, update
|
||||
# the issue with the provided data, else return with the
|
||||
# default states given.
|
||||
issue.created_at = request.data.get(
|
||||
"created_at", timezone.now()
|
||||
)
|
||||
issue.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue.save(update_fields=["created_at", "created_by"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "external_id and external_source are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from plane.api.serializers import (
|
|||
ModuleSerializer,
|
||||
)
|
||||
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 (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
|
|
@ -520,7 +520,6 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ from .project import (
|
|||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from .base import allow_permission, ROLE
|
||||
61
apiserver/plane/app/permissions/base.py
Normal file
61
apiserver/plane/app/permissions/base.py
Normal 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django.urls import path
|
||||
from plane.app.views import ApiTokenEndpoint
|
||||
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
|
|
@ -13,5 +13,10 @@ urlpatterns = [
|
|||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
ServiceApiTokenEndpoint.as_view(),
|
||||
name="service-api-tokens",
|
||||
),
|
||||
## End API Tokens
|
||||
]
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ urlpatterns = [
|
|||
name="inbox-issue",
|
||||
),
|
||||
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(
|
||||
{
|
||||
"get": "retrieve",
|
||||
|
|
|
|||
|
|
@ -174,8 +174,10 @@ from .module.archive import (
|
|||
ModuleArchiveUnarchiveEndpoint,
|
||||
)
|
||||
|
||||
from .api import ApiTokenEndpoint
|
||||
|
||||
from .api import (
|
||||
ApiTokenEndpoint,
|
||||
ServiceApiTokenEndpoint,
|
||||
)
|
||||
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
|
|
|
|||
|
|
@ -7,22 +7,22 @@ from django.utils import timezone
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.app.serializers import AnalyticViewSerializer
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||
from plane.db.models import AnalyticView, Issue, Workspace
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class AnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request, slug):
|
||||
x_axis = request.GET.get("x_axis", False)
|
||||
y_axis = request.GET.get("y_axis", False)
|
||||
|
|
@ -201,10 +201,10 @@ class AnalyticViewViewset(BaseViewSet):
|
|||
|
||||
|
||||
class SavedAnalyticEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request, slug, analytic_id):
|
||||
analytic_view = AnalyticView.objects.get(
|
||||
pk=analytic_id, workspace__slug=slug
|
||||
|
|
@ -234,10 +234,10 @@ class SavedAnalyticEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
|
||||
)
|
||||
def post(self, request, slug):
|
||||
x_axis = request.data.get("x_axis", False)
|
||||
y_axis = request.data.get("y_axis", False)
|
||||
|
|
@ -301,10 +301,10 @@ class ExportAnalyticsEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request, slug):
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
base_issues = Issue.issue_objects.filter(
|
||||
|
|
@ -380,12 +380,10 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||
.order_by("-count")
|
||||
)
|
||||
|
||||
open_estimate_sum = open_issues_queryset.aggregate(
|
||||
sum=Sum("point")
|
||||
)["sum"]
|
||||
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
|
||||
open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[
|
||||
"sum"
|
||||
]
|
||||
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"]
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
|||
def get(self, request, slug, pk=None):
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug
|
||||
user=request.user, workspace__slug=slug, is_service=False
|
||||
)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
@ -61,6 +61,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
|||
workspace__slug=slug,
|
||||
user=request.user,
|
||||
pk=pk,
|
||||
is_service=False,
|
||||
)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -78,3 +79,44 @@ class ApiTokenEndpoint(BaseAPIView):
|
|||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceOwnerPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
workspace=workspace,
|
||||
is_service=True,
|
||||
).first()
|
||||
|
||||
if api_token:
|
||||
return Response(
|
||||
{
|
||||
"token": str(api_token.token),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=str(uuid4().hex),
|
||||
description="Service Token",
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
is_service=True,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"token": str(api_token.token),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from django.utils import timezone
|
|||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
|
@ -34,10 +34,6 @@ from .. import BaseAPIView
|
|||
|
||||
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = UserFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
|
|
@ -292,6 +288,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = (
|
||||
|
|
@ -596,6 +593,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
|
|
@ -614,6 +612,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def delete(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
|
|
|
|||
|
|
@ -29,15 +29,14 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
allow_permission, ROLE
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
CycleSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
CycleWriteSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
|
|
@ -60,15 +59,6 @@ class CycleViewSet(BaseViewSet):
|
|||
serializer_class = CycleSerializer
|
||||
model = Cycle
|
||||
webhook_event = "cycle"
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
owned_by=self.request.user,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = UserFavorite.objects.filter(
|
||||
|
|
@ -325,6 +315,7 @@ class CycleViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
|
@ -611,6 +602,7 @@ class CycleViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
|
|
@ -684,6 +676,7 @@ class CycleViewSet(BaseViewSet):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
|
|
@ -771,6 +764,7 @@ class CycleViewSet(BaseViewSet):
|
|||
return Response(cycle, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = (
|
||||
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||
|
|
@ -1039,6 +1033,7 @@ class CycleViewSet(BaseViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
|
|
@ -1097,10 +1092,8 @@ class CycleViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class CycleDateCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
start_date = request.data.get("start_date", False)
|
||||
end_date = request.data.get("end_date", False)
|
||||
|
|
@ -1144,6 +1137,7 @@ class CycleFavoriteViewSet(BaseViewSet):
|
|||
.select_related("cycle", "cycle__owned_by")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
_ = UserFavorite.objects.create(
|
||||
project_id=project_id,
|
||||
|
|
@ -1153,6 +1147,7 @@ class CycleFavoriteViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, cycle_id):
|
||||
cycle_favorite = UserFavorite.objects.get(
|
||||
project=project_id,
|
||||
|
|
@ -1166,10 +1161,8 @@ class CycleFavoriteViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||
|
||||
|
|
@ -1579,10 +1572,8 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, cycle_id):
|
||||
cycle_properties = CycleUserProperties.objects.get(
|
||||
user=request.user,
|
||||
|
|
@ -1605,6 +1596,7 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
|||
serializer = CycleUserPropertiesSerializer(cycle_properties)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
cycle_properties, _ = CycleUserProperties.objects.get_or_create(
|
||||
user=request.user,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@ import json
|
|||
|
||||
# Django imports
|
||||
from django.core import serializers
|
||||
from django.db.models import (
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
)
|
||||
from django.db.models import F, Func, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
|
@ -17,16 +12,12 @@ from django.views.decorators.gzip import gzip_page
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
CycleIssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
|
|
@ -45,6 +36,7 @@ from plane.utils.paginator import (
|
|||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
|
|
@ -54,10 +46,6 @@ class CycleIssueViewSet(BaseViewSet):
|
|||
webhook_event = "cycle_issue"
|
||||
bulk = True
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
"issue__labels__id",
|
||||
"issue__assignees__id",
|
||||
|
|
@ -92,6 +80,7 @@ class CycleIssueViewSet(BaseViewSet):
|
|||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
|
@ -238,6 +227,7 @@ class CycleIssueViewSet(BaseViewSet):
|
|||
),
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
issues = request.data.get("issues", [])
|
||||
|
||||
|
|
@ -333,6 +323,7 @@ class CycleIssueViewSet(BaseViewSet):
|
|||
)
|
||||
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, cycle_id, issue_id):
|
||||
cycle_issue = CycleIssue.objects.filter(
|
||||
issue_id=issue_id,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from plane.db.models import (
|
|||
ProjectMember,
|
||||
User,
|
||||
Widget,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
|
@ -51,36 +52,61 @@ from .. import BaseAPIView
|
|||
|
||||
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
extra_filters = {}
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__lt=timezone.now().date(),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**extra_filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
created_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
).count()
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__lt=timezone.now().date(),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**extra_filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
completed_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
created_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
)
|
||||
.filter(**extra_filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(**extra_filters)
|
||||
.count()
|
||||
)
|
||||
|
||||
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_order = ["urgent", "high", "medium", "low", "none"]
|
||||
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):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_state_groups = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
|
@ -416,7 +460,7 @@ def dashboard_issues_by_state_groups(self, request, slug):
|
|||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("state__group")
|
||||
.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):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_priority = (
|
||||
Issue.issue_objects.filter(
|
||||
|
|
@ -447,7 +500,7 @@ def dashboard_issues_by_priority(self, request, slug):
|
|||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("priority")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ from rest_framework import status
|
|||
|
||||
# Module imports
|
||||
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.app.serializers import (
|
||||
EstimateSerializer,
|
||||
|
|
@ -23,10 +27,8 @@ def generate_random_name(length=10):
|
|||
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def get(self, request, slug, project_id):
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
if project.estimate_id is not None:
|
||||
|
|
@ -189,10 +191,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
|||
|
||||
|
||||
class EstimatePointEndpoint(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id, estimate_id):
|
||||
# TODO: add a key validation if the same key already exists
|
||||
if not request.data.get("key") or not request.data.get("value"):
|
||||
|
|
@ -211,6 +211,7 @@ class EstimatePointEndpoint(BaseViewSet):
|
|||
serializer = EstimatePointSerializer(estimate_point).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
|
|
@ -231,6 +232,7 @@ class EstimatePointEndpoint(BaseViewSet):
|
|||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
from rest_framework import status
|
||||
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.bgtasks.export_task import issue_export_task
|
||||
from plane.db.models import ExporterHistory, Project, Workspace
|
||||
|
|
@ -12,12 +12,10 @@ from .. import BaseAPIView
|
|||
|
||||
|
||||
class ExportIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = ExporterHistory
|
||||
serializer_class = ExporterHistorySerializer
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
|
@ -64,6 +62,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
exporter_history = ExporterHistory.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
|
|
|||
12
apiserver/plane/app/views/external/base.py
vendored
12
apiserver/plane/app/views/external/base.py
vendored
|
|
@ -11,7 +11,7 @@ from rest_framework import status
|
|||
|
||||
# Module imports
|
||||
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.app.serializers import (
|
||||
ProjectLiteSerializer,
|
||||
|
|
@ -21,10 +21,8 @@ from plane.license.utils.instance_value import get_configuration_value
|
|||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
|
|
@ -84,10 +82,10 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def post(self, request, slug):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ from rest_framework.response import Response
|
|||
|
||||
# Module imports
|
||||
from ..base import BaseViewSet
|
||||
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.app.permissions import (
|
||||
allow_permission, ROLE
|
||||
)
|
||||
from plane.db.models import (
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
|
|
@ -35,13 +37,10 @@ from plane.app.serializers import (
|
|||
InboxIssueDetailSerializer,
|
||||
)
|
||||
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):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxSerializer
|
||||
model = Inbox
|
||||
|
|
@ -63,6 +62,7 @@ class InboxViewSet(BaseViewSet):
|
|||
.select_related("workspace", "project")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id):
|
||||
inbox = self.get_queryset().first()
|
||||
return Response(
|
||||
|
|
@ -70,9 +70,11 @@ class InboxViewSet(BaseViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
inbox = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
|
|
@ -88,9 +90,6 @@ class InboxViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class InboxIssueViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxIssueSerializer
|
||||
model = InboxIssue
|
||||
|
|
@ -160,13 +159,15 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
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())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
|
|
@ -200,6 +201,14 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
if inbox_status:
|
||||
inbox_issue = inbox_issue.filter(status__in=inbox_status)
|
||||
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
inbox_issue = inbox_issue.filter(created_by=request.user)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(inbox_issue),
|
||||
|
|
@ -209,6 +218,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
).data,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id):
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
|
|
@ -311,12 +321,13 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
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(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
issue_id=issue_id,
|
||||
issue_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
inbox_id=inbox_id,
|
||||
|
|
@ -457,7 +468,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
|
|
@ -492,7 +503,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
)
|
||||
.get(
|
||||
inbox_id=inbox_id.id,
|
||||
issue_id=issue_id,
|
||||
issue_id=pk,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
|
|
@ -505,7 +516,12 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||
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(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
|
@ -533,9 +549,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
|
||||
)
|
||||
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
|
||||
)
|
||||
issue = InboxIssueDetailSerializer(inbox_issue).data
|
||||
return Response(
|
||||
|
|
@ -543,12 +557,13 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
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(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
issue_id=issue_id,
|
||||
issue_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
inbox_id=inbox_id,
|
||||
|
|
@ -558,21 +573,8 @@ class InboxIssueViewSet(BaseViewSet):
|
|||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from plane.app.serializers import (
|
|||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
|
||||
from plane.db.models import (
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
|
|
@ -33,6 +33,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||
]
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
filters = {}
|
||||
if request.GET.get("created_at__gt", None) is not None:
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ from plane.app.permissions import (
|
|||
from plane.app.serializers import (
|
||||
IssueFlatSerializer,
|
||||
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 (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
|
|
@ -46,15 +46,13 @@ from plane.utils.paginator import (
|
|||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
|
|
@ -98,6 +96,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def list(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
|
@ -213,6 +212,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
),
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
|
|
@ -256,6 +256,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def archive(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.issue_objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
@ -294,6 +295,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||
{"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def unarchive(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
@ -325,6 +327,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
|
|||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
|
|
@ -343,7 +346,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
|
|||
return Response(
|
||||
{
|
||||
"error_code": 4091,
|
||||
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
|
||||
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,19 +13,17 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
|||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueAttachmentSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueAttachment, ProjectMember
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import IssueAttachment
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = IssueAttachment
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueAttachmentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
|
|
@ -47,21 +45,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
if issue_attachment.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
|
@ -78,6 +64,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
|
|
|
|||
|
|
@ -25,17 +25,14 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
|
|
@ -60,15 +57,10 @@ from plane.utils.paginator import (
|
|||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
issue_ids = request.GET.get("issues", False)
|
||||
|
||||
|
|
@ -185,9 +177,6 @@ class IssueViewSet(BaseViewSet):
|
|||
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
|
|
@ -233,6 +222,7 @@ class IssueViewSet(BaseViewSet):
|
|||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
|
@ -257,6 +247,15 @@ class IssueViewSet(BaseViewSet):
|
|||
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 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):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
|
|
@ -412,6 +412,9 @@ class IssueViewSet(BaseViewSet):
|
|||
return Response(issue, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], creator=True, model=Issue
|
||||
)
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
|
|
@ -438,7 +441,8 @@ class IssueViewSet(BaseViewSet):
|
|||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
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())),
|
||||
),
|
||||
|
|
@ -483,6 +487,7 @@ class IssueViewSet(BaseViewSet):
|
|||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
|
|
@ -548,23 +553,11 @@ class IssueViewSet(BaseViewSet):
|
|||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Issue)
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
|
|
@ -582,10 +575,8 @@ class IssueViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
|
||||
def patch(self, request, slug, project_id):
|
||||
issue_property = IssueUserProperty.objects.get(
|
||||
user=request.user,
|
||||
|
|
@ -605,6 +596,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
|||
serializer = IssueUserPropertySerializer(issue_property)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
|
||||
def get(self, request, slug, project_id):
|
||||
issue_property, _ = IssueUserProperty.objects.get_or_create(
|
||||
user=request.user, project_id=project_id
|
||||
|
|
@ -614,23 +606,9 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def delete(self, request, slug, project_id):
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role__in=[15, 10, 5],
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists():
|
||||
|
||||
return Response(
|
||||
{"error": "Only admin can perform this action"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from plane.db.models import (
|
|||
IssueLabel,
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
|
|
@ -59,10 +59,16 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
|||
|
||||
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 (
|
||||
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
|
||||
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
|
||||
datetime.strptime(
|
||||
properties.get("start_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
> datetime.strptime(
|
||||
properties.get("target_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -73,7 +79,6 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
for issue in issues:
|
||||
|
||||
# Priority
|
||||
if properties.get("priority", False):
|
||||
bulk_issue_activities.append(
|
||||
|
|
|
|||
|
|
@ -16,22 +16,19 @@ from plane.app.serializers import (
|
|||
IssueCommentSerializer,
|
||||
CommentReactionSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE
|
||||
from plane.db.models import (
|
||||
IssueComment,
|
||||
ProjectMember,
|
||||
CommentReaction,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
webhook_event = "issue_comment"
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
"issue__id",
|
||||
|
|
@ -66,6 +63,7 @@ class IssueCommentViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueCommentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
|
|
@ -90,6 +88,11 @@ class IssueCommentViewSet(BaseViewSet):
|
|||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
|
||||
creator=True,
|
||||
model=IssueComment,
|
||||
)
|
||||
def partial_update(self, request, slug, project_id, issue_id, pk):
|
||||
issue_comment = IssueComment.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
@ -121,6 +124,9 @@ class IssueCommentViewSet(BaseViewSet):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment
|
||||
)
|
||||
def destroy(self, request, slug, project_id, issue_id, pk):
|
||||
issue_comment = IssueComment.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from plane.app.serializers import (
|
|||
IssueFlatSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ from rest_framework import status
|
|||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import LabelSerializer
|
||||
from plane.app.permissions import (
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Label,
|
||||
|
|
@ -25,7 +23,7 @@ class LabelViewSet(BaseViewSet):
|
|||
serializer_class = LabelSerializer
|
||||
model = Label
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
@ -45,6 +43,7 @@ class LabelViewSet(BaseViewSet):
|
|||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
|
|
@ -67,17 +66,20 @@ class LabelViewSet(BaseViewSet):
|
|||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def post(self, request, slug, project_id):
|
||||
label_data = request.data.get("label_data", [])
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from .. import BaseViewSet
|
|||
from plane.app.serializers import IssueLinkSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from .. import BaseViewSet
|
|||
from plane.app.serializers import IssueReactionSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.db.models import IssueReaction
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
class IssueReactionViewSet(BaseViewSet):
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from plane.db.models import (
|
|||
IssueAttachment,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
class IssueRelationViewSet(BaseViewSet):
|
||||
|
|
@ -80,8 +80,7 @@ class IssueRelationViewSet(BaseViewSet):
|
|||
).values_list("issue_id", flat=True)
|
||||
|
||||
queryset = (
|
||||
Issue.issue_objects
|
||||
.filter(workspace__slug=slug)
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from plane.db.models import (
|
|||
IssueLink,
|
||||
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 collections import defaultdict
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ from rest_framework.response import Response
|
|||
# Module imports
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
)
|
||||
|
||||
from plane.app.serializers import (
|
||||
ModuleDetailSerializer,
|
||||
ModuleLinkSerializer,
|
||||
|
|
@ -39,7 +41,7 @@ from plane.app.serializers import (
|
|||
ModuleUserPropertiesSerializer,
|
||||
ModuleWriteSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
Module,
|
||||
|
|
@ -48,7 +50,6 @@ from plane.db.models import (
|
|||
ModuleLink,
|
||||
ModuleUserProperties,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
|
@ -58,9 +59,6 @@ from .. import BaseAPIView, BaseViewSet
|
|||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
webhook_event = "module"
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
|
@ -318,6 +316,8 @@ class ModuleViewSet(BaseViewSet):
|
|||
.order_by("-is_favorite", "-created_at")
|
||||
)
|
||||
|
||||
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
serializer = ModuleWriteSerializer(
|
||||
|
|
@ -380,6 +380,8 @@ class ModuleViewSet(BaseViewSet):
|
|||
return Response(module, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
if self.fields:
|
||||
|
|
@ -427,6 +429,8 @@ class ModuleViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
|
|
@ -671,6 +675,7 @@ class ModuleViewSet(BaseViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
module = self.get_queryset().filter(pk=pk)
|
||||
|
||||
|
|
@ -740,25 +745,12 @@ class ModuleViewSet(BaseViewSet):
|
|||
return Response(module, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Module)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
if module.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the module"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list(
|
||||
"issue", flat=True
|
||||
|
|
@ -859,10 +851,8 @@ class ModuleFavoriteViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class ModuleUserPropertiesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, module_id):
|
||||
module_properties = ModuleUserProperties.objects.get(
|
||||
user=request.user,
|
||||
|
|
@ -885,6 +875,7 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
|
|||
serializer = ModuleUserPropertiesSerializer(module_properties)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, module_id):
|
||||
module_properties, _ = ModuleUserProperties.objects.get_or_create(
|
||||
user=request.user,
|
||||
|
|
|
|||
|
|
@ -17,13 +17,11 @@ from django.views.decorators.gzip import gzip_page
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
|
|
@ -46,6 +44,7 @@ from plane.utils.paginator import (
|
|||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
|
|
@ -57,10 +56,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
"issue__assignees__id",
|
||||
]
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
|
|
@ -96,6 +91,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
|
@ -203,6 +199,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
),
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
# create multiple issues inside a module
|
||||
def create_module_issues(self, request, slug, project_id, module_id):
|
||||
issues = request.data.get("issues", [])
|
||||
|
|
@ -244,6 +241,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
]
|
||||
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
# add multiple module inside an issue and remove multiple modules from an issue
|
||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||
modules = request.data.get("modules", [])
|
||||
|
|
@ -306,6 +304,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||
|
||||
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, module_id, issue_id):
|
||||
module_issue = ModuleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from plane.db.models import (
|
|||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
|
@ -39,6 +40,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
.select_related("workspace", "project," "triggered_by", "receiver")
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def list(self, request, slug):
|
||||
# Get query parameters
|
||||
snoozed = request.GET.get("snoozed", "false")
|
||||
|
|
@ -168,6 +173,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
serializer = NotificationSerializer(notifications, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
notification = Notification.objects.get(
|
||||
workspace__slug=slug, pk=pk, receiver=request.user
|
||||
|
|
@ -185,6 +194,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def mark_read(self, request, slug, pk):
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
|
|
@ -194,6 +206,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def mark_unread(self, request, slug, pk):
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
|
|
@ -203,6 +218,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def archive(self, request, slug, pk):
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
|
|
@ -212,6 +230,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def unarchive(self, request, slug, pk):
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
|
|
@ -223,6 +244,11 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
|
||||
|
||||
class UnreadNotificationEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def get(self, request, slug):
|
||||
# Watching Issues Count
|
||||
unread_notifications_count = (
|
||||
|
|
@ -260,6 +286,10 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class MarkAllReadNotificationViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def create(self, request, slug):
|
||||
snoozed = request.data.get("snoozed", False)
|
||||
archived = request.data.get("archived", False)
|
||||
|
|
@ -343,6 +373,9 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
|
|||
serializer_class = UserNotificationPreferenceSerializer
|
||||
|
||||
# request the object
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request):
|
||||
user_notification_preference = UserNotificationPreference.objects.get(
|
||||
user=request.user
|
||||
|
|
@ -353,6 +386,9 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# update the object
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def patch(self, request):
|
||||
user_notification_preference = UserNotificationPreference.objects.get(
|
||||
user=request.user
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from rest_framework import status
|
|||
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 (
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
|
|
@ -60,9 +60,6 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
|
|||
class PageViewSet(BaseViewSet):
|
||||
serializer_class = PageSerializer
|
||||
model = Page
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
|
|
@ -122,6 +119,7 @@ class PageViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
|
|
@ -143,6 +141,7 @@ class PageViewSet(BaseViewSet):
|
|||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
|
|
@ -208,6 +207,7 @@ class PageViewSet(BaseViewSet):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
page = self.get_queryset().filter(pk=pk).first()
|
||||
if page is None:
|
||||
|
|
@ -226,6 +226,7 @@ class PageViewSet(BaseViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
|
|
@ -235,6 +236,7 @@ class PageViewSet(BaseViewSet):
|
|||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
|
|
@ -245,6 +247,7 @@ class PageViewSet(BaseViewSet):
|
|||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def access(self, request, slug, project_id, pk):
|
||||
access = request.data.get("access", 0)
|
||||
page = Page.objects.filter(
|
||||
|
|
@ -267,11 +270,13 @@ class PageViewSet(BaseViewSet):
|
|||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
pages = PageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
|
|
@ -299,6 +304,7 @@ class PageViewSet(BaseViewSet):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
|
|
@ -328,6 +334,7 @@ class PageViewSet(BaseViewSet):
|
|||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
|
|
@ -370,12 +377,10 @@ class PageViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class PageFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
model = UserFavorite
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id, pk):
|
||||
_ = UserFavorite.objects.create(
|
||||
project_id=project_id,
|
||||
|
|
@ -385,6 +390,7 @@ class PageFavoriteViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page_favorite = UserFavorite.objects.get(
|
||||
project=project_id,
|
||||
|
|
@ -398,9 +404,6 @@ class PageFavoriteViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class PageLogEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = PageLogSerializer
|
||||
model = PageLog
|
||||
|
|
@ -440,9 +443,6 @@ class PageLogEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class SubPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug, project_id, page_id):
|
||||
|
|
@ -461,10 +461,8 @@ class SubPagesEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class PagesDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
page = (
|
||||
Page.objects.filter(
|
||||
|
|
@ -489,6 +487,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
|||
)
|
||||
return response
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
page = (
|
||||
Page.objects.filter(
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ from plane.app.serializers import (
|
|||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
)
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
|
|
@ -47,6 +48,7 @@ from plane.db.models import (
|
|||
ProjectMember,
|
||||
State,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
|
@ -57,10 +59,6 @@ class ProjectViewSet(BaseViewSet):
|
|||
model = Project
|
||||
webhook_event = "project"
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
sort_order = ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
|
|
@ -155,6 +153,10 @@ class ProjectViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def list(self, request, slug):
|
||||
fields = [
|
||||
field
|
||||
|
|
@ -173,11 +175,27 @@ class ProjectViewSet(BaseViewSet):
|
|||
projects, many=True
|
||||
).data,
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role__in=[5, 10],
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
projects = ProjectListSerializer(
|
||||
projects, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
|
|
@ -249,6 +267,7 @@ class ProjectViewSet(BaseViewSet):
|
|||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
|
@ -378,6 +397,7 @@ class ProjectViewSet(BaseViewSet):
|
|||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
|
@ -459,10 +479,7 @@ class ProjectViewSet(BaseViewSet):
|
|||
|
||||
class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
project.archived_at = timezone.now()
|
||||
|
|
@ -472,6 +489,7 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def delete(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
project.archived_at = None
|
||||
|
|
@ -480,10 +498,7 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class ProjectIdentifierEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
name = request.GET.get("name", "").strip().upper()
|
||||
|
||||
|
|
@ -502,6 +517,7 @@ class ProjectIdentifierEndpoint(BaseAPIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def delete(self, request, slug):
|
||||
name = request.data.get("name", "").strip().upper()
|
||||
|
||||
|
|
|
|||
|
|
@ -23,17 +23,16 @@ from plane.db.models import (
|
|||
Workspace,
|
||||
TeamMember,
|
||||
IssueUserProperty,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.app.permissions.base import allow_permission, ROLE
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "leave":
|
||||
|
|
@ -65,6 +64,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
# Get the list of members to be added to the project and their roles i.e. the user_id and the role
|
||||
members = request.data.get("members", [])
|
||||
|
|
@ -88,6 +88,23 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
member.get("member_id"): member.get("role") for member in members
|
||||
}
|
||||
|
||||
# check the workspace role of the new user
|
||||
for member in member_roles:
|
||||
workspace_member_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=member,
|
||||
is_active=True,
|
||||
).role
|
||||
if workspace_member_role in [5, 10] and member_roles.get(
|
||||
member
|
||||
) in [15, 20]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role higher than the workspace role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Update roles in the members array based on the member_roles dictionary and set is_active to True
|
||||
for project_member in ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
|
|
@ -172,6 +189,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
# Return the serialized data
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
# Get the list of project members for the project
|
||||
project_members = ProjectMember.objects.filter(
|
||||
|
|
@ -186,6 +204,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk,
|
||||
|
|
@ -205,6 +224,22 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=project_member.member,
|
||||
is_active=True,
|
||||
).role
|
||||
if workspace_role in [5, 10] and int(
|
||||
request.data.get("role", project_member.role)
|
||||
) in [15, 20]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role higher than the workspace role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role))
|
||||
|
|
@ -226,6 +261,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
@ -262,6 +298,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
project_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
|
||||
def leave(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ from rest_framework.response import Response
|
|||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
)
|
||||
from plane.db.models import Issue, ProjectMember
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
|
|
@ -76,6 +74,16 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member=self.request.user,
|
||||
is_active=True,
|
||||
role=5
|
||||
).exists():
|
||||
issues = issues.filter(
|
||||
created_by=self.request.user
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ from django.db import transaction
|
|||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkspaceEntityPermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueViewSerializer,
|
||||
|
|
@ -58,9 +58,6 @@ from plane.db.models import (
|
|||
class WorkspaceViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
model = IssueView
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
|
|
@ -78,6 +75,32 @@ class WorkspaceViewViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def list(self, request, slug):
|
||||
queryset = self.get_queryset()
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
views = IssueViewSerializer(
|
||||
queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(views, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
with transaction.atomic():
|
||||
workspace_view = IssueView.objects.select_for_update().get(
|
||||
|
|
@ -111,6 +134,12 @@ class WorkspaceViewViewSet(BaseViewSet):
|
|||
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):
|
||||
workspace_view = IssueView.objects.get(
|
||||
pk=pk,
|
||||
|
|
@ -157,10 +186,6 @@ class WorkspaceViewViewSet(BaseViewSet):
|
|||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
|
|
@ -223,7 +248,8 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
|||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
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())),
|
||||
),
|
||||
|
|
@ -231,6 +257,10 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
|||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
|
@ -241,6 +271,16 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
|||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
issue_queryset = issue_queryset.filter(
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
|
|
@ -347,9 +387,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
|||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
model = IssueView
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
|
|
@ -383,8 +420,20 @@ class IssueViewViewSet(BaseViewSet):
|
|||
.distinct()
|
||||
)
|
||||
|
||||
allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
|
|
@ -395,6 +444,8 @@ class IssueViewViewSet(BaseViewSet):
|
|||
).data
|
||||
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):
|
||||
with transaction.atomic():
|
||||
issue_view = IssueView.objects.select_for_update().get(
|
||||
|
|
@ -427,6 +478,8 @@ class IssueViewViewSet(BaseViewSet):
|
|||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
project_view = IssueView.objects.get(
|
||||
pk=pk,
|
||||
|
|
@ -471,6 +524,8 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
|||
.select_related("view")
|
||||
)
|
||||
|
||||
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
_ = UserFavorite.objects.create(
|
||||
user=request.user,
|
||||
|
|
@ -480,6 +535,8 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
|||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
|
||||
def destroy(self, request, slug, project_id, view_id):
|
||||
view_favorite = UserFavorite.objects.get(
|
||||
project=project_id,
|
||||
|
|
|
|||
|
|
@ -9,15 +9,13 @@ from rest_framework.response import Response
|
|||
from plane.db.models import Webhook, WebhookLog, Workspace
|
||||
from plane.db.models.webhook import generate_token
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import WorkspaceOwnerPermission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
|
||||
class WebhookEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceOwnerPermission,
|
||||
]
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
try:
|
||||
|
|
@ -40,6 +38,7 @@ class WebhookEndpoint(BaseAPIView):
|
|||
)
|
||||
raise IntegrityError
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug, pk=None):
|
||||
if pk is None:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug)
|
||||
|
|
@ -79,6 +78,7 @@ class WebhookEndpoint(BaseAPIView):
|
|||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def patch(self, request, slug, pk):
|
||||
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
|
||||
serializer = WebhookSerializer(
|
||||
|
|
@ -104,6 +104,7 @@ class WebhookEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def delete(self, request, slug, pk):
|
||||
webhook = Webhook.objects.get(pk=pk, workspace__slug=slug)
|
||||
webhook.delete()
|
||||
|
|
@ -111,10 +112,8 @@ class WebhookEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class WebhookSecretRegenerateEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceOwnerPermission,
|
||||
]
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def post(self, request, slug, pk):
|
||||
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
|
||||
webhook.secret_key = generate_token()
|
||||
|
|
@ -124,10 +123,8 @@ class WebhookSecretRegenerateEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class WebhookLogsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceOwnerPermission,
|
||||
]
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug, webhook_id):
|
||||
webhook_logs = WebhookLog.objects.filter(
|
||||
workspace__slug=slug, webhook_id=webhook_id
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ from django.db.models import Q
|
|||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import UserFavorite, Workspace
|
||||
from plane.app.serializers import UserFavoriteSerializer
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
|
||||
class WorkspaceFavoriteEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request, slug):
|
||||
# the second filter is to check if the user is a member of the project
|
||||
favorites = UserFavorite.objects.filter(
|
||||
|
|
@ -34,6 +34,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
|||
serializer = UserFavoriteSerializer(favorites, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
|
|
@ -46,6 +49,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def patch(self, request, slug, favorite_id):
|
||||
favorite = UserFavorite.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=favorite_id
|
||||
|
|
@ -58,6 +64,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def delete(self, request, slug, favorite_id):
|
||||
favorite = UserFavorite.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=favorite_id
|
||||
|
|
@ -67,10 +76,10 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
|||
|
||||
|
||||
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
|
||||
)
|
||||
def get(self, request, slug, favorite_id):
|
||||
favorites = UserFavorite.objects.filter(
|
||||
user=request.user,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from plane.authentication.adapter.error import (
|
|||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(APIView):
|
||||
|
|
@ -37,6 +38,10 @@ class MagicGenerateEndpoint(APIView):
|
|||
AllowAny,
|
||||
]
|
||||
|
||||
throttle_classes = [
|
||||
AuthenticationThrottle,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
# Check if instance is configured
|
||||
instance = Instance.objects.first()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from django.db.models import Q
|
|||
from django.utils import timezone
|
||||
|
||||
# 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.utils.exception_logger import log_exception
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,23 @@ from django.core.management import BaseCommand
|
|||
class Command(BaseCommand):
|
||||
help = "Clear Cache before starting the server to remove stale values"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument(
|
||||
"--key", type=str, nargs="?", help="Key to clear cache"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
if options["key"]:
|
||||
cache.delete(options["key"])
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Cache Cleared for key: {options['key']}"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
cache.clear()
|
||||
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -42,6 +42,7 @@ class FileAsset(BaseModel):
|
|||
related_name="assets",
|
||||
)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class DeployBoard(WorkspaceBaseModel):
|
|||
)
|
||||
is_votes_enabled = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=dict)
|
||||
is_activity_enabled = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the deploy board"""
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class UserFavorite(WorkspaceBaseModel):
|
|||
entity_identifier = models.UUIDField(null=True, blank=True)
|
||||
name = models.CharField(max_length=255, blank=True, null=True)
|
||||
is_folder = models.BooleanField(default=False)
|
||||
sequence = models.IntegerField(default=65535)
|
||||
sequence = models.FloatField(default=65535)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -31,7 +31,12 @@ class UserFavorite(WorkspaceBaseModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"]
|
||||
unique_together = [
|
||||
"entity_type",
|
||||
"user",
|
||||
"entity_identifier",
|
||||
"deleted_at",
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_type", "entity_identifier", "user"],
|
||||
|
|
|
|||
|
|
@ -3,43 +3,54 @@ from django.db import models
|
|||
from django.db.models import Q
|
||||
|
||||
# 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)
|
||||
description = models.TextField(blank=True)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
is_default = models.BooleanField(default=False)
|
||||
weight = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
level = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "name", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "project"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="issue_type_unique_name_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Type"
|
||||
verbose_name_plural = "Issue Types"
|
||||
db_table = "issue_types"
|
||||
ordering = ("sort_order",)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If we are adding a new issue type, we need to set the sort order
|
||||
if self._state.adding:
|
||||
# Get the largest sort order for the project
|
||||
largest_sort_order = IssueType.objects.filter(
|
||||
project=self.project
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
# If there are issue types, set the sort order to the largest + 10000
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
super(IssueType, self).save(*args, **kwargs)
|
||||
|
||||
class ProjectIssueType(ProjectBaseModel):
|
||||
issue_type = models.ForeignKey(
|
||||
"db.IssueType",
|
||||
related_name="project_issue_types",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
level = models.PositiveIntegerField(default=0)
|
||||
is_default = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "issue_type", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "issue_type"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Issue Type"
|
||||
verbose_name_plural = "Project Issue Types"
|
||||
db_table = "project_issue_types"
|
||||
ordering = ("project", "issue_type")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project} - {self.issue_type}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class InstanceSerializer(BaseSerializer):
|
|||
model = Instance
|
||||
exclude = [
|
||||
"license_key",
|
||||
"user_count"
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
|
|||
data["is_activated"] = True
|
||||
# Get all the configuration
|
||||
(
|
||||
ENABLE_SIGNUP,
|
||||
IS_GOOGLE_ENABLED,
|
||||
IS_GITHUB_ENABLED,
|
||||
GITHUB_APP_NAME,
|
||||
|
|
@ -70,6 +71,10 @@ class InstanceEndpoint(BaseAPIView):
|
|||
INTERCOM_APP_ID,
|
||||
) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"default": os.environ.get("ENABLE_SIGNUP", "0"),
|
||||
},
|
||||
{
|
||||
"key": "IS_GOOGLE_ENABLED",
|
||||
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
|
||||
|
|
@ -132,6 +137,7 @@ class InstanceEndpoint(BaseAPIView):
|
|||
|
||||
data = {}
|
||||
# Authentication
|
||||
data["enable_signup"] = ENABLE_SIGNUP == "1"
|
||||
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
|
||||
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
||||
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from plane.app.serializers import (
|
|||
IssueStateInboxSerializer,
|
||||
)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from django.db.models import (
|
|||
JSONField,
|
||||
Value,
|
||||
OuterRef,
|
||||
Func
|
||||
Func,
|
||||
)
|
||||
|
||||
# Third Party imports
|
||||
|
|
@ -61,7 +61,7 @@ from plane.db.models import (
|
|||
ProjectPublicMember,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,14 +27,11 @@ class ProjectStatesEndpoint(BaseAPIView):
|
|||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
states = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=deploy_board.workspace.slug,
|
||||
project_id=deploy_board.project_id,
|
||||
)
|
||||
.values("name", "group", "color", "id")
|
||||
)
|
||||
states = State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=deploy_board.workspace.slug,
|
||||
project_id=deploy_board.project_id,
|
||||
).values("name", "group", "color", "id", "sequence")
|
||||
|
||||
return Response(
|
||||
states,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from plane.db.models import (
|
|||
|
||||
|
||||
def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
|
||||
FIELD_MAPPER = {
|
||||
"label_ids": "labels__id",
|
||||
"assignee_ids": "assignees__id",
|
||||
|
|
@ -30,7 +29,10 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
|||
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
|
||||
"module_ids": (
|
||||
"issue_module__module_id",
|
||||
~Q(issue_module__module_id__isnull=True),
|
||||
(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
),
|
||||
),
|
||||
}
|
||||
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):
|
||||
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.14
|
||||
Django==4.2.15
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@ export DOCKERHUB_USER=makeplane
|
|||
export PULL_POLICY=${PULL_POLICY:-if_not_present}
|
||||
|
||||
CPU_ARCH=$(uname -m)
|
||||
OS_NAME=$(uname)
|
||||
UPPER_CPU_ARCH=$(tr '[:lower:]' '[:upper:]' <<< "$CPU_ARCH")
|
||||
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
SED_PREFIX=()
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
SED_PREFIX=("-i" "")
|
||||
else
|
||||
SED_PREFIX=("-i")
|
||||
fi
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
|
|
@ -51,12 +60,12 @@ function spinner() {
|
|||
}
|
||||
|
||||
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
|
||||
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"
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -78,7 +87,7 @@ function initialize(){
|
|||
else
|
||||
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 "build"
|
||||
return 1
|
||||
|
|
@ -122,7 +131,7 @@ function updateEnvFile() {
|
|||
return
|
||||
else
|
||||
# if key exists, update the value
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { Extensions } from "@tiptap/core";
|
||||
import { SlashCommand } from "@/extensions";
|
||||
// hooks
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
issueEmbedConfig: TIssueEmbedConfig | undefined;
|
||||
};
|
||||
|
|
@ -12,7 +16,7 @@ type Props = {
|
|||
export const DocumentEditorAdditionalExtensions = (props: Props) => {
|
||||
const { fileHandler } = props;
|
||||
|
||||
const extensions = [SlashCommand(fileHandler.upload)];
|
||||
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useDocumentEditor } from "@/hooks/use-document-editor";
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
import {
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
aiHandler?: TAIHandler;
|
||||
containerClassName?: string;
|
||||
disabledExtensions?: TExtensions[];
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
|
|
@ -31,7 +43,10 @@ interface IDocumentEditor {
|
|||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const {
|
||||
aiHandler,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
|
|
@ -44,16 +59,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
|||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
// use document editor
|
||||
const { editor, isIndexedDbSynced } = useDocumentEditor({
|
||||
disabledExtensions,
|
||||
id,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
|
|
@ -64,7 +73,6 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
|||
forwardedRef,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
|
|
@ -78,9 +86,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
|||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
aiHandler={aiHandler}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,18 +15,21 @@ import { Editor, ReactRenderer } from "@tiptap/react";
|
|||
// components
|
||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
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 = {
|
||||
aiHandler?: TAIHandler;
|
||||
displayConfig: TDisplayConfig;
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
hideDragHandle?: () => void;
|
||||
id: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { editor, editorContainerClassName, hideDragHandle, id, tabIndex } = props;
|
||||
const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
// states
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
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}>
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
hideDragHandle={hideDragHandle}
|
||||
id={id}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor && editor.isEditable && <BlockMenu editor={editor} />}
|
||||
{editor.isEditable && (
|
||||
<>
|
||||
<BlockMenu editor={editor} />
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</>
|
||||
)}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
{isOpen && linkViewProps && coordinates && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
|
|
@ -10,12 +12,13 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
|||
// plane web types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
tabIndex?: number;
|
||||
|
|
@ -29,6 +32,7 @@ interface IDocumentReadOnlyEditor {
|
|||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
id,
|
||||
|
|
@ -39,17 +43,17 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
|||
mentionHandler,
|
||||
} = props;
|
||||
const editor = useReadOnlyEditor({
|
||||
initialValue,
|
||||
editorClassName,
|
||||
mentionHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
extensions: [
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler?.issue.widgetCallback,
|
||||
}),
|
||||
],
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
|
|
@ -61,7 +65,13 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
|||
});
|
||||
|
||||
return (
|
||||
<PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} id={id} tabIndex={tabIndex} />
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
|
||||
interface EditorContainerProps {
|
||||
children: ReactNode;
|
||||
displayConfig: TDisplayConfig;
|
||||
editor: Editor | null;
|
||||
editorContainerClassName: string;
|
||||
hideDragHandle?: () => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { children, editor, editorContainerClassName, hideDragHandle, id } = props;
|
||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
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 (
|
||||
<div
|
||||
id={`editor-container-${id}`}
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={hideDragHandle}
|
||||
onMouseLeave={handleContainerMouseLeave}
|
||||
className={cn(
|
||||
"cursor-text relative",
|
||||
"editor-container cursor-text relative",
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
},
|
||||
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
||||
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
||||
editorContainerClassName
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Editor, Extension } from "@tiptap/core";
|
||||
// components
|
||||
import { EditorContainer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// hooks
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
|
@ -11,16 +13,15 @@ import { EditorContentWrapper } from "./editor-content";
|
|||
type Props = IEditorProps & {
|
||||
children?: (editor: Editor) => React.ReactNode;
|
||||
extensions: Extension<any, any>[];
|
||||
hideDragHandleOnMouseLeave: () => void;
|
||||
};
|
||||
|
||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
const {
|
||||
children,
|
||||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
hideDragHandleOnMouseLeave,
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
|
|
@ -57,10 +58,10 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
|||
|
||||
return (
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
>
|
||||
{children?.(editor)}
|
||||
<div className="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
|||
|
||||
const extensions = [EnterKeyExtension(onEnterKeyPress)];
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />;
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// components
|
||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
|
|
@ -8,12 +10,20 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
|||
import { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
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({
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
|
|
@ -24,7 +34,12 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
|||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName} id={id}>
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} id={id} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,30 @@
|
|||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { forwardRef, useCallback } from "react";
|
||||
// components
|
||||
import { EditorWrapper } from "@/components/editors";
|
||||
import { EditorBubbleMenu } from "@/components/menus";
|
||||
// extensions
|
||||
import { DragAndDrop, SlashCommand } from "@/extensions";
|
||||
import { SideMenuExtension, SlashCommand } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled, fileHandler } = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
SlashCommand(fileHandler.upload),
|
||||
// TODO; add the extension conditionally for forms that don't require it
|
||||
// EnterKeyExtension(onEnterKeyPress),
|
||||
];
|
||||
const extensions = [SlashCommand(fileHandler.upload)];
|
||||
|
||||
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction));
|
||||
extensions.push(
|
||||
SideMenuExtension({
|
||||
aiEnabled: false,
|
||||
dragDropEnabled: !!dragDropEnabled,
|
||||
})
|
||||
);
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, fileHandler.upload]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}>
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
|
||||
</EditorWrapper>
|
||||
);
|
||||
|
|
|
|||
95
packages/editor/src/core/components/menus/ai-menu.tsx
Normal file
95
packages/editor/src/core/components/menus/ai-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
|||
|
||||
const handleClickDragHandle = useCallback((event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
|
||||
if (target.matches("#drag-handle")) {
|
||||
event.preventDefault();
|
||||
|
||||
popup.current?.setProps({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./bubble-menu";
|
||||
export * from "./ai-menu";
|
||||
export * from "./block-menu";
|
||||
export * from "./menu-items";
|
||||
|
|
|
|||
7
packages/editor/src/core/constants/config.ts
Normal file
7
packages/editor/src/core/constants/config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
|
||||
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||
fontSize: "large-font",
|
||||
fontStyle: "sans-serif",
|
||||
};
|
||||
|
|
@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
|
|||
</Tooltip>
|
||||
|
||||
<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>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => void) =>
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: "enterKey",
|
||||
|
||||
|
|
@ -8,7 +8,9 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) =>
|
|||
return {
|
||||
Enter: () => {
|
||||
if (!this.editor.storage.mentionsOpen) {
|
||||
onEnterKeyPress?.(this.editor.getHTML());
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export * from "./typography";
|
|||
export * from "./core-without-props";
|
||||
export * from "./document-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./drag-drop";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
export * from "./extensions";
|
||||
|
|
@ -18,4 +17,5 @@ export * from "./horizontal-rule";
|
|||
export * from "./keymap";
|
||||
export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./slash-commands";
|
||||
|
|
|
|||
205
packages/editor/src/core/extensions/side-menu.tsx
Normal file
205
packages/editor/src/core/extensions/side-menu.tsx
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -3,9 +3,9 @@ import Collaboration from "@tiptap/extension-collaboration";
|
|||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { DragAndDrop, IssueWidget } from "@/extensions";
|
||||
import { IssueWidget, SideMenuExtension } from "@/extensions";
|
||||
// hooks
|
||||
import { TFileHandler, useEditor } from "@/hooks/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// plane editor provider
|
||||
|
|
@ -13,9 +13,10 @@ import { CollaborationProvider } from "@/plane-editor/providers";
|
|||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
embedHandler?: TEmbedConfig;
|
||||
|
|
@ -29,13 +30,13 @@ type DocumentEditorProps = {
|
|||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
};
|
||||
|
||||
export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
|
|
@ -46,7 +47,6 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
|||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
|
@ -93,7 +93,10 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
|||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
DragAndDrop(setHideDragHandleFunction),
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled: true,
|
||||
}),
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
|
|
@ -102,6 +105,7 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
|||
document: provider.document,
|
||||
}),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
}),
|
||||
|
|
@ -111,5 +115,8 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
|||
tabIndex,
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced };
|
||||
return {
|
||||
editor,
|
||||
isIndexedDbSynced,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
|
||||
// components
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// extensions
|
||||
|
|
@ -14,22 +15,7 @@ import { CollaborationProvider } from "@/plane-editor/providers";
|
|||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import {
|
||||
DeleteImage,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
RestoreImage,
|
||||
TEditorCommands,
|
||||
UploadImage,
|
||||
} from "@/types";
|
||||
|
||||
export type TFileHandler = {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
editorClassName: string;
|
||||
|
|
@ -54,26 +40,30 @@ export interface CustomEditorProps {
|
|||
value?: string | null | undefined;
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
enableHistory,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id = "",
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
}: CustomEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
export const useEditor = (props: CustomEditorProps) => {
|
||||
const {
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
enableHistory,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id = "",
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
const editor = useTiptapEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps(editorClassName),
|
||||
...CoreEditorProps({
|
||||
editorClassName,
|
||||
}),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [
|
||||
|
|
@ -95,18 +85,10 @@ export const useEditor = ({
|
|||
...extensions,
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: async () => {
|
||||
handleEditorReady?.(true);
|
||||
},
|
||||
onTransaction: async ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
onChange?.(editor.getJSON(), editor.getHTML());
|
||||
},
|
||||
onDestroy: async () => {
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
onCreate: () => handleEditorReady?.(true),
|
||||
onTransaction: ({ editor }) => setSavedSelection(editor.state.selection),
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export const useReadOnlyEditor = ({
|
|||
editable: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps(editorClassName),
|
||||
...CoreReadOnlyEditorProps({
|
||||
editorClassName,
|
||||
}),
|
||||
...editorProps,
|
||||
},
|
||||
onCreate: async () => {
|
||||
|
|
|
|||
153
packages/editor/src/core/plugins/ai-handle.ts
Normal file
153
packages/editor/src/core/plugins/ai-handle.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,68 +1,35 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
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
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
// extensions
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
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 verticalEllipsisIcon =
|
||||
'<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>';
|
||||
|
||||
const createDragHandleElement = (): HTMLElement => {
|
||||
const dragHandleElement = document.createElement("button");
|
||||
dragHandleElement.type = "button";
|
||||
dragHandleElement.id = "drag-handle";
|
||||
dragHandleElement.draggable = true;
|
||||
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");
|
||||
dragHandleContainer.classList.add("drag-handle-container");
|
||||
dragHandleElement.appendChild(dragHandleContainer);
|
||||
const iconElement1 = document.createElement("span");
|
||||
iconElement1.classList.value = "pointer-events-none text-custom-text-300";
|
||||
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");
|
||||
dotsContainer.classList.add("drag-handle-dots");
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spanElement = document.createElement("span");
|
||||
spanElement.classList.add("drag-handle-dot");
|
||||
dotsContainer.appendChild(spanElement);
|
||||
}
|
||||
|
||||
dragHandleContainer.appendChild(dotsContainer);
|
||||
dragHandleElement.appendChild(iconElement1);
|
||||
dragHandleElement.appendChild(iconElement2);
|
||||
|
||||
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 elements = document.elementsFromPoint(coords.x, coords.y);
|
||||
const generalSelectors = [
|
||||
|
|
@ -98,7 +65,7 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const nodePosAtDOM = (node: Element, view: EditorView, options: DragHandleOptions) => {
|
||||
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
|
|
@ -132,7 +99,7 @@ const calcNodePos = (pos: number, view: EditorView, node: Element) => {
|
|||
return safePos;
|
||||
};
|
||||
|
||||
const DragHandle = (options: DragHandleOptions) => {
|
||||
export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
|
||||
let listType = "";
|
||||
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
||||
view.focus();
|
||||
|
|
@ -222,15 +189,15 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||
if (!(node instanceof Element)) return;
|
||||
|
||||
if (node.matches("blockquote")) {
|
||||
let nodePosForBlockquotes = nodePosAtDOMForBlockQuotes(node, view);
|
||||
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
|
||||
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));
|
||||
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
|
||||
|
||||
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
|
||||
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
|
||||
// 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));
|
||||
}
|
||||
return;
|
||||
|
|
@ -253,152 +220,98 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
// drag handle view actions
|
||||
const hideDragHandle = () => dragHandleElement?.classList.add("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) => {
|
||||
dragHandleElement = createDragHandleElement();
|
||||
dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
|
||||
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("dragHandle"),
|
||||
view: (view) => {
|
||||
dragHandleElement = createDragHandleElement();
|
||||
dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
|
||||
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const frameRenderer = document.querySelector(".frame-renderer");
|
||||
if (!frameRenderer) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const frameRenderer = document.querySelector(".frame-renderer");
|
||||
if (!frameRenderer) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
|
||||
}
|
||||
hideDragHandle();
|
||||
|
||||
sideMenu?.appendChild(dragHandleElement);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
const domEvents = {
|
||||
mousemove: () => showDragHandle(),
|
||||
dragenter: (view: EditorView) => {
|
||||
view.dom.classList.add("dragging");
|
||||
hideDragHandle();
|
||||
},
|
||||
drop: (view: EditorView, event: DragEvent) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
hideDragHandle();
|
||||
if (!dropPos) return;
|
||||
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
let isDroppedInsideList = false;
|
||||
|
||||
// Traverse up the document tree to find if we're inside a list item
|
||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||
if (resolvedPos.node(i).type.name === "listItem") {
|
||||
isDroppedInsideList = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
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")) {
|
||||
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");
|
||||
hideDragHandle();
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
let isDroppedInsideList = false;
|
||||
|
||||
// Traverse up the document tree to find if we're inside a list item
|
||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||
if (resolvedPos.node(i).type.name === "listItem") {
|
||||
isDroppedInsideList = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
dragend: (view: EditorView) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
view,
|
||||
domEvents,
|
||||
};
|
||||
};
|
||||
|
|
@ -2,7 +2,13 @@ import { EditorProps } from "@tiptap/pm/view";
|
|||
// helpers
|
||||
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 {
|
||||
attributes: {
|
||||
class: cn(
|
||||
|
|
@ -25,4 +31,4 @@ export function CoreEditorProps(editorClassName: string): EditorProps {
|
|||
return html.replace(/<img.*?>/g, "");
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { EditorProps } from "@tiptap/pm/view";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// props
|
||||
import { TCoreEditorProps } from "@/props";
|
||||
|
||||
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
});
|
||||
export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps => {
|
||||
const { editorClassName } = props;
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
7
packages/editor/src/core/types/ai.ts
Normal file
7
packages/editor/src/core/types/ai.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
type TMenuProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export type TAIHandler = {
|
||||
menu?: (props: TMenuProps) => React.ReactNode;
|
||||
};
|
||||
17
packages/editor/src/core/types/config.ts
Normal file
17
packages/editor/src/core/types/config.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// hooks
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
|
||||
import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands, TFileHandler } from "@/types";
|
||||
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
|
|
@ -22,10 +20,13 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
|||
isEditorReadyToDiscard: () => boolean;
|
||||
setSynced: () => void;
|
||||
hasUnsyncedChanges: () => boolean;
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
}
|
||||
|
||||
export interface IEditorProps {
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
|
|
@ -36,7 +37,7 @@ export interface IEditorProps {
|
|||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEnterKeyPress?: (descriptionHTML: string) => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
|
|
@ -50,6 +51,7 @@ export interface IRichTextEditor extends IEditorProps {
|
|||
|
||||
export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
|
|
|
|||
1
packages/editor/src/core/types/extensions.ts
Normal file
1
packages/editor/src/core/types/extensions.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type TExtensions = "ai" | "issue-embed";
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
export * from "./ai";
|
||||
export * from "./config";
|
||||
export * from "./editor";
|
||||
export * from "./embed";
|
||||
export * from "./extensions";
|
||||
export * from "./image";
|
||||
export * from "./mention-suggestion";
|
||||
export * from "./slash-commands-suggestion";
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export type TEditorCommands =
|
|||
| "code"
|
||||
| "table"
|
||||
| "image"
|
||||
| "divider";
|
||||
| "divider"
|
||||
| "issue-embed";
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
|
|
|
|||
|
|
@ -34,5 +34,5 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
|
|||
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
|
||||
// types
|
||||
export type { CustomEditorProps, TFileHandler } from "@/hooks/use-editor";
|
||||
export type { CustomEditorProps } from "@/hooks/use-editor";
|
||||
export * from "@/types";
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue