Compare commits

..

8 Commits

Author SHA1 Message Date
sriram veeraghanta 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghanta c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghanta 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghanta e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghanta 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghanta 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghanta 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghanta c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
867 changed files with 15856 additions and 23288 deletions
+2 -9
View File
@@ -55,19 +55,12 @@ body:
- Safari
- Other
- type: dropdown
id: variant
id: version
attributes:
label: Variant
label: Version
options:
- Cloud
- Self-hosted
- Local
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: v0.17.0-dev
validations:
required: true
+84
View File
@@ -0,0 +1,84 @@
name: Auto Merge or Create PR on Push
on:
workflow_dispatch:
push:
branches:
- "sync/**"
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }}
jobs:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4.1.1
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
- name: Setup GH CLI and Git Config
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Check for merge conflicts
id: conflicts
run: |
git fetch origin $TARGET_BRANCH
git checkout $TARGET_BRANCH
# Attempt to merge the main branch into the current branch
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
echo "No merge conflicts detected."
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
else
echo "Merge conflicts detected."
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
git merge --abort
fi
- name: Merge Change to Target Branch
if: env.HAS_CONFLICTS == 'false'
run: |
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
git push origin $TARGET_BRANCH
- name: Create PR to Target Branch
if: env.HAS_CONFLICTS == 'true'
run: |
# Replace 'username' with the actual GitHub username of the reviewer.
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
echo "Pull Request created: $PR_URL"
+1 -1
View File
@@ -3,7 +3,7 @@ name: "CodeQL"
on:
workflow_dispatch:
push:
branches: ["preview", "master"]
branches: ["develop", "preview", "master"]
pull_request:
branches: ["develop", "preview", "master"]
schedule:
+30 -44
View File
@@ -1,53 +1,28 @@
name: Create PR on Sync
name: Create Sync Action
on:
workflow_dispatch:
push:
branches:
- "sync/**"
- preview
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
sync_changes:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
contents: read
steps:
- name: Checkout code
- name: Checkout Code
uses: actions/checkout@v4.1.1
with:
fetch-depth: 0 # Fetch all history for all branches and tags
persist-credentials: false
fetch-depth: 0
- name: Setup Git
run: |
git config user.name "$ACCOUNT_USER_NAME"
git config user.email "$ACCOUNT_USER_EMAIL"
- name: Setup GH CLI and Git Config
- name: Setup GH CLI
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
@@ -56,14 +31,25 @@ jobs:
sudo apt update
sudo apt install gh -y
- name: Create PR to Target Branch
- name: Push Changes to Target Repo A
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
echo "Pull Request created: $PR_URL"
fi
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
- name: Push Changes to Target Repo B
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
-44
View File
@@ -1,44 +0,0 @@
name: Sync Repositories
on:
workflow_dispatch:
push:
branches:
- preview
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
runs-on: ubuntu-20.04
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4.1.1
with:
persist-credentials: false
fetch-depth: 0
- name: Setup GH CLI
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
+10 -5
View File
@@ -27,7 +27,7 @@ RUN yarn install
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build
@@ -89,17 +89,21 @@ RUN chmod -R 777 /code
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next ./apps/space/.next
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
@@ -114,6 +118,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
-44
View File
@@ -1,44 +0,0 @@
# Security Policy
This document outlines security procedures and vulnerabilities reporting for the Plane project.
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
## Out of Scope Vulnerabilities
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
- Attacks requiring MITM or physical access to a user's device.
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
- Email spoofing.
- Missing DNSSEC, CAA, CSP headers.
- Lack of Secure or HTTP only flag on non-sensitive cookies.
## Reporting Process
If you discover a vulnerability, please adhere to the following reporting process:
1. Email your findings to security@plane.so.
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
## Our Commitment
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
reference: https://supabase.com/.well-known/security.txt
+11 -1
View File
@@ -32,18 +32,28 @@ RUN apk add --no-cache --virtual .build-deps \
apk del .build-deps
RUN addgroup -S plane && \
adduser -S captain -G plane
RUN chown captain.plane /code
USER captain
# Add in Django deps and generate Django's static files
COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
RUN chown -R captain:plane /code
USER captain
# Expose container port and run entry point script
EXPOSE 8000
+4
View File
@@ -30,13 +30,17 @@ ADD requirements ./requirements
# Install the local development settings
RUN pip install -r requirements/local.txt --compile --no-cache-dir
RUN addgroup -S plane && \
adduser -S captain -G plane
COPY . .
RUN mkdir -p /code/plane/logs
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code
USER captain
# Expose container port and run entry point script
EXPOSE 8000
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.18.0"
"version": "0.17.0"
}
+4 -4
View File
@@ -66,11 +66,11 @@ class BaseSerializer(serializers.ModelSerializer):
if expand in self.fields:
# Import all the expandable serializers
from . import (
IssueSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
)
# Expansion mapper
+2 -2
View File
@@ -79,7 +79,7 @@ class IssueSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["description_html"] = parsed_str
except Exception:
except Exception as e:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
@@ -366,7 +366,7 @@ class IssueCommentSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["comment_html"] = parsed_str
except Exception:
except Exception as e:
raise serializers.ValidationError("Invalid HTML passed")
return data
@@ -7,7 +7,6 @@ from plane.db.models import (
ProjectIdentifier,
WorkspaceMember,
)
from .base import BaseSerializer
-3
View File
@@ -1,6 +1,5 @@
# Module imports
from plane.db.models import User
from .base import BaseSerializer
@@ -11,9 +10,7 @@ class UserLiteSerializer(BaseSerializer):
"id",
"first_name",
"last_name",
"email",
"avatar",
"display_name",
"email",
]
read_only_fields = fields
+1 -1
View File
@@ -34,7 +34,7 @@ urlpatterns = [
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/archive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(),
name="cycle-archive-unarchive",
),
-6
View File
@@ -6,15 +6,9 @@ from plane.api.views import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
WorkspaceIssueAPIEndpoint.as_view(),
name="issue-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueAPIEndpoint.as_view(),
+1 -1
View File
@@ -12,7 +12,7 @@ urlpatterns = [
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/",
ProjectAPIEndpoint.as_view(),
name="project",
),
-1
View File
@@ -3,7 +3,6 @@ from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
from .state import StateAPIEndpoint
from .issue import (
WorkspaceIssueAPIEndpoint,
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
+1 -7
View File
@@ -7,7 +7,6 @@ import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
@@ -166,12 +165,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
@property
def project_id(self):
project_id = self.kwargs.get("project_id", None)
if project_id:
return project_id
if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)
return self.kwargs.get("project_id", None)
@property
def fields(self):
+22 -244
View File
@@ -2,31 +2,29 @@
import json
# Django imports
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.db.models import Q, Count, Sum, F, OuterRef, Func
from django.utils import timezone
from django.core import serializers
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework import status
# Module imports
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity
from .base import BaseAPIView, WebhookMixin
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueAttachment,
CycleIssue,
IssueLink,
IssueAttachment,
)
from plane.utils.analytics_plot import burndown_plot
from .base import BaseAPIView, WebhookMixin
from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
@@ -154,7 +152,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
data,
status=status.HTTP_200_OK,
)
queryset = self.get_queryset().filter(archived_at__isnull=True)
queryset = (
self.get_queryset().filter(archived_at__isnull=True)
)
cycle_view = request.GET.get("cycle_view", "all")
# Current Cycle
@@ -493,29 +493,24 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data,
)
def post(self, request, slug, project_id, cycle_id):
def post(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
pk=pk, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, cycle_id):
def delete(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
pk=pk, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
@@ -556,21 +551,7 @@ class CycleIssueAPIEndpoint(BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, cycle_id, issue_id=None):
# Get
if issue_id:
cycle_issue = CycleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
# List
def get(self, request, slug, project_id, cycle_id):
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -746,7 +727,7 @@ class CycleIssueAPIEndpoint(BaseAPIView):
class TransferCycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `create` actions for transferring the issues into a particular cycle.
This viewset provides `create` actions for transfering the issues into a particular cycle.
"""
@@ -767,209 +748,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
)
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
# Get the assignee distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
if current_cycle:
current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
}
# Save the snapshot of the current cycle
current_cycle.save(update_fields=["progress_snapshot"])
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
+10 -30
View File
@@ -2,28 +2,27 @@
import json
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.db.models import (
Inbox,
InboxIssue,
Issue,
Project,
ProjectMember,
State,
ProjectMember,
Project,
Inbox,
)
from .base import BaseAPIView
from plane.bgtasks.issue_activites_task import issue_activity
class InboxIssueAPIEndpoint(BaseAPIView):
@@ -135,11 +134,10 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
@@ -272,9 +270,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
current_instance = json.dumps(
InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder
)
if serializer.is_valid():
serializer.save()
@@ -303,7 +298,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug,
@@ -314,21 +309,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="inbox.activity.created",
requested_data=json.dumps(
request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
+6 -66
View File
@@ -48,72 +48,10 @@ from plane.db.models import (
ProjectMember,
)
from .base import BaseAPIView
from .base import BaseAPIView, WebhookMixin
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `retrieveByIssueId` on workspace level
"""
model = Issue
webhook_event = "issue"
permission_classes = [ProjectEntityPermission]
serializer_class = IssueSerializer
@property
def project__identifier(self):
return self.kwargs.get("project__identifier", None)
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__identifier=self.kwargs.get("project__identifier"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
def get(
self, request, slug, project__identifier=None, issue__identifier=None
):
if issue__identifier and project__identifier:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
).get(
workspace__slug=slug,
project__identifier=project__identifier,
sequence_id=issue__identifier,
)
return Response(
IssueSerializer(
issue,
fields=self.fields,
expand=self.expand,
).data,
status=status.HTTP_200_OK,
)
class IssueAPIEndpoint(BaseAPIView):
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue.
@@ -344,7 +282,7 @@ class IssueAPIEndpoint(BaseAPIView):
)
if serializer.is_valid():
if (
request.data.get("external_id")
str(request.data.get("external_id"))
and (issue.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
@@ -370,6 +308,8 @@ class IssueAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
external_id__isnull=False,
external_source__isnull=False,
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
@@ -655,7 +595,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueCommentAPIEndpoint(BaseAPIView):
class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue.
+15 -23
View File
@@ -2,33 +2,32 @@
import json
# Django imports
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.db.models import Count, Prefetch, Q, F, Func, OuterRef
from django.utils import timezone
from django.core import serializers
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.api.serializers import (
IssueSerializer,
ModuleIssueSerializer,
ModuleSerializer,
)
from .base import BaseAPIView, WebhookMixin
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Project,
Module,
ModuleLink,
Issue,
ModuleIssue,
IssueAttachment,
IssueLink,
Module,
ModuleIssue,
ModuleLink,
Project,
)
from .base import BaseAPIView, WebhookMixin
from plane.api.serializers import (
ModuleSerializer,
ModuleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
@@ -260,7 +259,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
@@ -554,7 +553,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id, pk):
def get(self, request, slug, project_id):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -570,13 +569,6 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
if module.status not in ["completed", "cancelled"]:
return Response(
{
"error": "Only completed or cancelled modules can be archived"
},
status=status.HTTP_400_BAD_REQUEST,
)
module.archived_at = timezone.now()
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+21 -24
View File
@@ -1,29 +1,27 @@
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from plane.api.serializers import ProjectSerializer
from plane.app.permissions import ProjectBasePermission
# Module imports
from plane.db.models import (
Cycle,
Inbox,
IssueProperty,
Module,
Project,
ProjectDeployBoard,
ProjectMember,
State,
Workspace,
Project,
ProjectMember,
ProjectDeployBoard,
State,
Cycle,
Module,
IssueProperty,
Inbox,
)
from plane.app.permissions import ProjectBasePermission
from plane.api.serializers import ProjectSerializer
from .base import BaseAPIView, WebhookMixin
@@ -105,8 +103,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, pk=None):
if pk is None:
def get(self, request, slug, project_id=None):
if project_id is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
@@ -137,7 +135,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
expand=self.expand,
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
serializer = ProjectSerializer(
project,
fields=self.fields,
@@ -261,10 +259,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, pk):
def patch(self, request, slug, project_id=None):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
project = Project.objects.get(pk=project_id)
if project.archived_at:
return Response(
@@ -291,11 +289,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=pk,
project_id=project_id,
color="#ff7700",
is_triage=True,
)
project = (
@@ -325,8 +322,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+8 -12
View File
@@ -1,16 +1,16 @@
# Django imports
from django.db import IntegrityError
from django.db.models import Q
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, State
from rest_framework import status
# Module imports
from .base import BaseAPIView
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import State, Issue
class StateAPIEndpoint(BaseAPIView):
@@ -28,8 +28,8 @@ class StateAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(is_triage=False)
.filter(project__archived_at__isnull=True)
.filter(~Q(name="Triage"))
.select_related("project")
.select_related("workspace")
.distinct()
@@ -86,11 +86,7 @@ class StateAPIEndpoint(BaseAPIView):
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(
self.get_queryset().get(pk=state_id),
fields=self.fields,
expand=self.expand,
)
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@@ -105,7 +101,7 @@ class StateAPIEndpoint(BaseAPIView):
def delete(self, request, slug, project_id, state_id):
state = State.objects.get(
is_triage=False,
~Q(name="Triage"),
pk=state_id,
project_id=project_id,
workspace__slug=slug,
+2 -12
View File
@@ -1,8 +1,8 @@
# Third Party imports
from rest_framework.permissions import SAFE_METHODS, BasePermission
from rest_framework.permissions import BasePermission, SAFE_METHODS
# Module import
from plane.db.models import ProjectMember, WorkspaceMember
from plane.db.models import WorkspaceMember, ProjectMember
# Permission Mappings
Admin = 20
@@ -79,16 +79,6 @@ class ProjectEntityPermission(BasePermission):
if request.user.is_anonymous:
return False
# Handle requests based on project__identifier
if hasattr(view, "project__identifier") and view.project__identifier:
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
project__identifier=view.project__identifier,
is_active=True,
).exists()
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
@@ -59,7 +59,6 @@ from .issue import (
IssueFlatSerializer,
IssueStateSerializer,
IssueLinkSerializer,
IssueInboxSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
@@ -93,7 +92,6 @@ from .page import (
PageSerializer,
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageFavoriteSerializer,
)
@@ -109,7 +107,6 @@ from .inbox import (
InboxIssueSerializer,
IssueStateInboxSerializer,
InboxIssueLiteSerializer,
InboxIssueDetailSerializer,
)
from .analytic import AnalyticViewSerializer
+4 -53
View File
@@ -3,11 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import (
IssueInboxSerializer,
LabelLiteSerializer,
IssueDetailSerializer,
)
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
@@ -28,62 +24,17 @@ class InboxSerializer(BaseSerializer):
class InboxIssueSerializer(BaseSerializer):
issue = IssueInboxSerializer(read_only=True)
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = InboxIssue
fields = [
"id",
"status",
"duplicate_to",
"snoozed_till",
"source",
"issue",
"created_by",
]
fields = "__all__"
read_only_fields = [
"project",
"workspace",
]
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
instance.issue.label_ids = instance.label_ids
return super().to_representation(instance)
class InboxIssueDetailSerializer(BaseSerializer):
issue = IssueDetailSerializer(read_only=True)
duplicate_issue_detail = IssueInboxSerializer(
read_only=True, source="duplicate_to"
)
class Meta:
model = InboxIssue
fields = [
"id",
"status",
"duplicate_to",
"snoozed_till",
"duplicate_issue_detail",
"source",
"issue",
]
read_only_fields = [
"project",
"workspace",
]
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "assignee_ids"):
instance.issue.assignee_ids = instance.assignee_ids
if hasattr(instance, "label_ids"):
instance.issue.label_ids = instance.label_ids
return super().to_representation(instance)
class InboxIssueLiteSerializer(BaseSerializer):
class Meta:
+1 -21
View File
@@ -620,26 +620,6 @@ class IssueStateSerializer(DynamicBaseSerializer):
fields = "__all__"
class IssueInboxSerializer(DynamicBaseSerializer):
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Issue
fields = [
"id",
"name",
"priority",
"sequence_id",
"project_id",
"created_at",
"label_ids",
]
read_only_fields = fields
class IssueSerializer(DynamicBaseSerializer):
# ids
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -708,7 +688,7 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
is_subscribed = serializers.BooleanField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
@@ -210,7 +210,6 @@ class ModuleSerializer(DynamicBaseSerializer):
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
]
read_only_fields = fields
+12 -31
View File
@@ -3,6 +3,9 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
Page,
PageLog,
@@ -14,33 +17,22 @@ from plane.db.models import (
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Page
fields = [
"id",
"name",
"owned_by",
"access",
"color",
"labels",
"parent",
"is_favorite",
"is_locked",
"archived_at",
"workspace",
"project",
"created_at",
"updated_at",
"created_by",
"updated_by",
"view_props",
]
fields = "__all__"
read_only_fields = [
"workspace",
"project",
@@ -56,12 +48,8 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description_html = self.context["description_html"]
page = Page.objects.create(
**validated_data,
description_html=description_html,
project_id=project_id,
owned_by_id=owned_by_id,
**validated_data, project_id=project_id, owned_by_id=owned_by_id
)
if labels is not None:
@@ -103,13 +91,6 @@ class PageSerializer(BaseSerializer):
return super().update(instance, validated_data)
class PageDetailSerializer(PageSerializer):
description_html = serializers.CharField()
class Meta(PageSerializer.Meta):
fields = PageSerializer.Meta.fields + ["description_html"]
class SubPageSerializer(BaseSerializer):
entity_details = serializers.SerializerMethodField()
-5
View File
@@ -101,9 +101,4 @@ urlpatterns = [
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/<uuid:pk>/",
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
]
+2 -2
View File
@@ -30,7 +30,7 @@ urlpatterns = [
name="inbox",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssueViewSet.as_view(
{
"get": "list",
@@ -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>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:issue_id>/",
InboxIssueViewSet.as_view(
{
"get": "retrieve",
-5
View File
@@ -121,9 +121,4 @@ urlpatterns = [
ModuleArchiveUnarchiveEndpoint.as_view(),
name="module-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/",
ModuleArchiveUnarchiveEndpoint.as_view(),
name="module-archive-unarchive",
),
]
+64 -13
View File
@@ -31,51 +31,102 @@ urlpatterns = [
),
name="project-pages",
),
# favorite pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
PageFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
PageFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-pages",
),
# archived pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
PageViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
PageViewSet.as_view(
{
"post": "archive",
"delete": "unarchive",
}
),
name="project-page-archive-unarchive",
name="project-page-archive",
),
# lock and unlock
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unarchive/",
PageViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-page-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
PageViewSet.as_view(
{
"get": "archive_list",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
PageViewSet.as_view(
{
"post": "lock",
"delete": "unlock",
}
),
name="project-pages-lock-unlock",
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unlock/",
PageViewSet.as_view(
{
"post": "unlock",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/<uuid:transaction>/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
SubPagesEndpoint.as_view(),
name="sub-page",
),
+3 -8
View File
@@ -38,7 +38,7 @@ from .workspace.base import (
WorkSpaceAvailabilityCheckEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
ExportWorkspaceUserActivityEndpoint,
ExportWorkspaceUserActivityEndpoint
)
from .workspace.member import (
@@ -91,14 +91,12 @@ from .cycle.base import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleArchiveUnarchiveEndpoint,
CycleUserPropertiesEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue.base import (
@@ -172,6 +170,7 @@ from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleArchiveUnarchiveEndpoint,
ModuleUserPropertiesEndpoint,
)
@@ -179,10 +178,6 @@ from .module.issue import (
ModuleIssueViewSet,
)
from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import ApiTokenEndpoint
-409
View File
@@ -1,409 +0,0 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
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.db.models import (
Cycle,
CycleFavorite,
Issue,
Label,
User,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
"archived_at",
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(archived_at__isnull=False)
.filter(pk=pk)
)
data = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
)
.first()
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
# Label Distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.end_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
cycle_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+195 -9
View File
@@ -1,9 +1,10 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Django imports
from django.db.models import (
Case,
CharField,
@@ -20,10 +21,11 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import status
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -538,13 +540,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.first()
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
@@ -684,6 +679,197 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
)
& Q(
issue_cycle__issue__assignees__member_project__is_active=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def get(self, request, slug, project_id):
queryset = (
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"status",
"archived_at",
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
+3 -4
View File
@@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
@@ -40,7 +40,7 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class CycleIssueViewSet(BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@@ -249,7 +249,6 @@ class CycleIssueViewSet(BaseViewSet):
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
@@ -257,7 +256,7 @@ class CycleIssueViewSet(BaseViewSet):
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(old_cycle_id),
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
+5 -10
View File
@@ -571,16 +571,14 @@ def dashboard_recent_collaborators(self, request, slug):
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=lambda qs: self.get_results_controller(qs, slug),
controller=self.get_results_controller,
)
class DashboardEndpoint(BaseAPIView):
def get_results_controller(self, project_members_with_activities, slug):
def get_results_controller(self, project_members_with_activities):
user_active_issue_counts = (
User.objects.filter(
id__in=project_members_with_activities,
)
User.objects.filter(id__in=project_members_with_activities)
.annotate(
active_issue_count=Count(
Case(
@@ -589,13 +587,10 @@ class DashboardEndpoint(BaseAPIView):
"unstarted",
"started",
],
issue_assignee__issue__workspace__slug=slug,
issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("issue_assignee__issue__id"),
then=1,
),
output_field=IntegerField(),
),
distinct=True,
)
)
)
.values("active_issue_count", user_id=F("id"))
+132 -227
View File
@@ -3,7 +3,7 @@ import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
@@ -24,15 +24,16 @@ from plane.db.models import (
State,
IssueLink,
IssueAttachment,
Project,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
InboxIssueDetailSerializer,
IssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@@ -63,20 +64,13 @@ class InboxViewSet(BaseViewSet):
.select_related("workspace", "project")
)
def list(self, request, slug, project_id):
inbox = self.get_queryset().first()
return Response(
InboxSerializer(inbox).data,
status=status.HTTP_200_OK,
)
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
inbox = Inbox.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
).first()
)
# Handle default inbox delete
if inbox.is_default:
return Response(
@@ -104,6 +98,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -167,49 +162,51 @@ class InboxIssueViewSet(BaseViewSet):
)
).distinct()
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
inbox_id=inbox_id.id, project_id=project_id, **filters
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
if self.expand:
issues = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
.select_related("issue")
.prefetch_related(
"issue__labels",
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
).order_by(request.GET.get("order_by", "-issue__created_at"))
# inbox status filter
inbox_status = [
item
for item in request.GET.get("status", "-2").split(",")
if item != "null"
]
if inbox_status:
inbox_issue = inbox_issue.filter(status__in=inbox_status)
return self.paginate(
request=request,
queryset=(inbox_issue),
on_results=lambda inbox_issues: InboxIssueSerializer(
inbox_issues,
many=True,
).data,
return Response(
issues,
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id):
def create(self, request, slug, project_id, inbox_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"},
@@ -232,88 +229,49 @@ class InboxIssueViewSet(BaseViewSet):
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data.get("issue"),
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
if serializer.is_valid():
serializer.save()
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data["id"]),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "in-app"),
)
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(
inbox_id=inbox_id.id,
issue_id=serializer.data["id"],
project_id=project_id,
)
)
serializer = InboxIssueDetailSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
def partial_update(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
@@ -338,12 +296,9 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
@@ -391,9 +346,7 @@ class InboxIssueViewSet(BaseViewSet):
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
current_instance = json.dumps(
InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder
)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
@@ -421,7 +374,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug,
@@ -431,108 +384,60 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="inbox.activity.created",
requested_data=json.dumps(
request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
)
inbox_issue = (
InboxIssue.objects.filter(
inbox_id=inbox_id.id,
issue_id=serializer.data["id"],
project_id=project_id,
)
.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value(
[],
output_field=ArrayField(UUIDField()),
),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
Value(
[],
output_field=ArrayField(UUIDField()),
),
),
).first()
)
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
inbox_issue = (
InboxIssue.objects.select_related("issue")
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = (
self.get_queryset()
.filter(pk=issue_id)
.prefetch_related(
"issue__labels",
"issue__assignees",
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
.get(
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
)
)
issue = InboxIssueDetailSerializer(inbox_issue).data
return Response(
issue,
status=status.HTTP_200_OK,
)
def destroy(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Requested object was not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
+36 -38
View File
@@ -1,59 +1,57 @@
# Python imports
import json
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
IssuePropertySerializer,
IssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssuePropertySerializer,
IssueSerializer,
from plane.db.models import (
Project,
Issue,
IssueProperty,
IssueLink,
IssueAttachment,
IssueSubscriber,
IssueReaction,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -244,7 +242,7 @@ class IssueListEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK)
class IssueViewSet(BaseViewSet):
class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
+2 -2
View File
@@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
@@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(BaseViewSet):
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
+4 -2
View File
@@ -1,7 +1,6 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
@@ -20,12 +19,14 @@ from django.db.models import (
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
@@ -119,6 +120,7 @@ class IssueDraftViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
-356
View File
@@ -1,356 +0,0 @@
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Count,
Exists,
F,
Func,
IntegerField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
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.serializers import (
ModuleDetailSerializer,
)
from plane.db.models import (
Issue,
Module,
ModuleFavorite,
ModuleLink,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
cancelled_issues = (
Issue.issue_objects.filter(
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_issues = (
Issue.issue_objects.filter(
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
started_issues = (
Issue.issue_objects.filter(
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
unstarted_issues = (
Issue.issue_objects.filter(
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
backlog_issues = (
Issue.issue_objects.filter(
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
total_issues = (
Issue.issue_objects.filter(
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("workspace", "project", "lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
completed_issues=Coalesce(
Subquery(completed_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
cancelled_issues=Coalesce(
Subquery(cancelled_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
started_issues=Coalesce(
Subquery(started_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
unstarted_issues=Coalesce(
Subquery(unstarted_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_issues=Coalesce(
Subquery(backlog_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
total_issues=Coalesce(
Subquery(total_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
)
return Response(modules, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
module_id=pk,
)
return Response(
data,
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
if module.status not in ["completed", "cancelled"]:
return Response(
{
"error": "Only completed or cancelled modules can be archived"
},
status=status.HTTP_400_BAD_REQUEST,
)
module.archived_at = timezone.now()
module.save()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+161
View File
@@ -515,6 +515,167 @@ class ModuleLinkViewSet(BaseViewSet):
)
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
def get(self, request, slug, project_id):
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
)
return Response(modules, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = timezone.now()
module.save()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+2 -2
View File
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, WebhookMixin
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
@@ -33,7 +33,7 @@ from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class ModuleIssueViewSet(BaseViewSet):
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
+40 -68
View File
@@ -1,7 +1,5 @@
# Python imports
import json
from datetime import datetime
from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
@@ -19,7 +17,6 @@ from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
)
from plane.db.models import (
Page,
@@ -31,8 +28,6 @@ from plane.db.models import (
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
@@ -92,21 +87,11 @@ class PageViewSet(BaseViewSet):
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description_html": request.data.get(
"description_html", "<p></p>"
),
},
context={"project_id": project_id, "owned_by_id": request.user.id},
)
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -140,25 +125,9 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = PageDetailSerializer(
page, data=request.data, partial=True
)
page_description = page.description_html
serializer = PageSerializer(page, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{
"description_html": page_description,
},
cls=DjangoJSONEncoder,
),
page_id=pk,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -171,30 +140,18 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
if page is None:
return Response(
{"error": "Page not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
return Response(
PageDetailSerializer(page).data, status=status.HTTP_200_OK
)
def lock(self, request, slug, project_id, pk):
def lock(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, pk):
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
page.is_locked = False
@@ -203,13 +160,13 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
queryset = self.get_queryset().filter(archived_at__isnull=True)
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, pk):
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner or admin can archive the page
@@ -227,16 +184,13 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
unarchive_archive_page_and_descendants(pk, datetime.now())
unarchive_archive_page_and_descendants(page_id, datetime.now())
return Response(
{"archived_at": str(datetime.now())},
status=status.HTTP_200_OK,
)
return Response(status=status.HTTP_204_NO_CONTENT)
def unarchive(self, request, slug, project_id, pk):
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner or admin can un archive the page
@@ -259,10 +213,19 @@ class PageViewSet(BaseViewSet):
page.parent = None
page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(pk, None)
unarchive_archive_page_and_descendants(page_id, None)
return Response(status=status.HTTP_204_NO_CONTENT)
def archive_list(self, request, slug, project_id):
pages = Page.objects.filter(
project_id=project_id,
workspace__slug=slug,
).filter(archived_at__isnull=False)
pages = PageSerializer(pages, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
@@ -306,20 +269,29 @@ class PageFavoriteViewSet(BaseViewSet):
serializer_class = PageFavoriteSerializer
model = PageFavorite
def create(self, request, slug, project_id, pk):
_ = PageFavorite.objects.create(
project_id=project_id,
page_id=pk,
user=request.user,
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(archived_at__isnull=True)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("page", "page__owned_by")
)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, pk):
def create(self, request, slug, project_id):
serializer = PageFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, page_id):
page_favorite = PageFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
page_id=pk,
page_id=page_id,
)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -2
View File
@@ -393,11 +393,10 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = (
+8 -12
View File
@@ -1,6 +1,9 @@
# Python imports
from itertools import groupby
# Django imports
from django.db.models import Q
# Third party imports
from rest_framework.response import Response
from rest_framework import status
@@ -14,7 +17,6 @@ from plane.app.permissions import (
from plane.db.models import State, Issue
from plane.utils.cache import invalidate_cache
class StateViewSet(BaseViewSet):
serializer_class = StateSerializer
model = State
@@ -33,15 +35,13 @@ class StateViewSet(BaseViewSet):
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.filter(is_triage=False)
.filter(~Q(name="Triage"))
.select_related("project")
.select_related("workspace")
.distinct()
)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
def create(self, request, slug, project_id):
serializer = StateSerializer(data=request.data)
if serializer.is_valid():
@@ -62,9 +62,7 @@ class StateViewSet(BaseViewSet):
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
def mark_as_default(self, request, slug, project_id, pk):
# Select all the states which are marked as default
_ = State.objects.filter(
@@ -75,12 +73,10 @@ class StateViewSet(BaseViewSet):
).update(default=True)
return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
is_triage=False,
~Q(name="Triage"),
pk=pk,
project_id=project_id,
workspace__slug=slug,
+1 -6
View File
@@ -49,12 +49,7 @@ class UserEndpoint(BaseViewSet):
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
)
@invalidate_cache(
path="/api/users/me/",
)
@invalidate_cache(
path="/api/users/me/settings/",
)
@invalidate_cache(path="/api/users/me/")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
+2 -2
View File
@@ -326,11 +326,11 @@ class IssueViewFavoriteViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, view_id):
view_favorite = IssueViewFavorite.objects.get(
view_favourite = IssueViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favorite.delete()
view_favourite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+2 -2
View File
@@ -151,8 +151,8 @@ class WorkSpaceViewSet(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
@invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
@invalidate_cache(path="/api/users/me/settings/")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@@ -27,7 +27,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.filter(archived_at__isnull=False)
.annotate(
total_issues=Count(
"issue_cycle",
+16 -29
View File
@@ -1,39 +1,36 @@
# Python imports
from datetime import datetime
import jwt
from datetime import datetime
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Count
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Count
from django.utils import timezone
# Third party modules
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import (
WorkSpaceMemberInviteSerializer,
WorkSpaceMemberSerializer,
WorkSpaceMemberInviteSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from .. import BaseViewSet
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
WorkspaceMember,
)
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from .. import BaseViewSet
from plane.app.permissions import WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.utils.cache import invalidate_cache
class WorkspaceInvitationsViewset(BaseViewSet):
"""Endpoint for creating, listing and deleting workspaces"""
@@ -169,14 +166,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
"""Invitation response endpoint the user can respond to the invitation"""
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
user=False,
multiple=True,
url_params=True,
)
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
@invalidate_cache(path="/api/users/me/workspaces/")
def post(self, request, slug, pk):
workspace_invite = WorkspaceMemberInvite.objects.get(
pk=pk, workspace__slug=slug
@@ -274,7 +264,10 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
@invalidate_cache(path="/api/users/me/workspaces/")
@invalidate_cache(
path="/api/workspaces/:slug/members/", url_params=True, user=False
)
def create(self, request):
invitations = request.data.get("invitations", [])
workspace_invitations = WorkspaceMemberInvite.objects.filter(
@@ -283,12 +276,6 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
# If the user is already a member of workspace and was deactivated then activate the user
for invitation in workspace_invitations:
invalidate_cache_directly(
path=f"/api/workspaces/{invitation.workspace.slug}/members/",
user=False,
request=request,
multiple=True,
)
# Update the WorkspaceMember for this specific invitation
WorkspaceMember.objects.filter(
workspace_id=invitation.workspace_id, member=request.user
+4 -19
View File
@@ -102,10 +102,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
url_params=True,
user=False,
multiple=True,
path="/api/workspaces/:slug/members/", url_params=True, user=False
)
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
@@ -150,15 +147,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
url_params=True,
user=False,
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
@invalidate_cache(
path="/api/users/me/workspaces/", user=False, multiple=True
path="/api/workspaces/:slug/members/", url_params=True, user=False
)
@invalidate_cache(path="/api/users/me/settings/")
def destroy(self, request, slug, pk):
# Check the user role who is deleting the user
workspace_member = WorkspaceMember.objects.get(
@@ -224,15 +215,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
url_params=True,
user=False,
multiple=True,
path="/api/workspaces/:slug/members/", url_params=True, user=False
)
@invalidate_cache(path="/api/users/me/settings/")
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
def leave(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug,
@@ -30,7 +30,6 @@ class WorkspaceModulesEndpoint(BaseAPIView):
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.filter(archived_at__isnull=False)
.prefetch_related(
Prefetch(
"link_module",
@@ -21,7 +21,6 @@ class WorkspaceStatesEndpoint(BaseAPIView):
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
is_triage=False,
)
serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
-679
View File
@@ -1,679 +0,0 @@
# Python imports
import uuid
import random
from datetime import datetime, timedelta
# Django imports
from django.db.models import Max
# Third party imports
from celery import shared_task
from faker import Faker
# Module imports
from plane.db.models import (
Workspace,
User,
Project,
ProjectMember,
State,
Label,
Cycle,
Module,
Issue,
IssueSequence,
IssueAssignee,
IssueLabel,
IssueActivity,
CycleIssue,
ModuleIssue,
Page,
PageLabel,
Inbox,
InboxIssue,
)
def create_project(workspace, user_id):
fake = Faker()
name = fake.name()
unique_id = str(uuid.uuid4())[:5]
project = Project.objects.create(
workspace=workspace,
name=f"{name}_{unique_id}",
identifier=name[
: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)
].upper(),
created_by_id=user_id,
inbox_view=True,
)
# Add current member as project member
_ = ProjectMember.objects.create(
project=project,
member_id=user_id,
role=20,
)
return project
def create_project_members(workspace, project, members):
members = User.objects.filter(email__in=members)
_ = ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
workspace=workspace,
member=member,
role=20,
sort_order=random.randint(0, 65535),
)
for member in members
],
ignore_conflicts=True,
)
return
def create_states(workspace, project, user_id):
states = [
{
"name": "Backlog",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",
},
]
states = State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=project,
sequence=state["sequence"],
workspace=workspace,
group=state["group"],
default=state.get("default", False),
created_by_id=user_id,
)
for state in states
]
)
return states
def create_labels(workspace, project, user_id):
fake = Faker()
Faker.seed(0)
return Label.objects.bulk_create(
[
Label(
name=fake.color_name(),
color=fake.hex_color(),
project=project,
workspace=workspace,
created_by_id=user_id,
sort_order=random.randint(0, 65535),
)
for _ in range(0, 50)
],
ignore_conflicts=True,
)
def create_cycles(workspace, project, user_id, cycle_count):
fake = Faker()
Faker.seed(0)
cycles = []
used_date_ranges = set() # Track used date ranges
while len(cycles) <= cycle_count:
# Generate a start date, allowing for None
start_date_option = [None, fake.date_this_year()]
start_date = start_date_option[random.randint(0, 1)]
# Initialize end_date based on start_date
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
# Ensure end_date is strictly after start_date if start_date is not None
while start_date is not None and (
end_date <= start_date
or (start_date, end_date) in used_date_ranges
):
end_date = fake.date_this_year()
# Add the unique date range to the set
(
used_date_ranges.add((start_date, end_date))
if (end_date is not None and start_date is not None)
else None
)
# Append the cycle with unique date range
cycles.append(
Cycle(
name=fake.name(),
owned_by_id=user_id,
sort_order=random.randint(0, 65535),
start_date=start_date,
end_date=end_date,
project=project,
workspace=workspace,
)
)
return Cycle.objects.bulk_create(cycles, ignore_conflicts=True)
def create_modules(workspace, project, user_id, module_count):
fake = Faker()
Faker.seed(0)
modules = []
for _ in range(0, module_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
modules.append(
Module(
name=fake.name(),
sort_order=random.randint(0, 65535),
start_date=start_date,
target_date=end_date,
project=project,
workspace=workspace,
)
)
return Module.objects.bulk_create(modules, ignore_conflicts=True)
def create_pages(workspace, project, user_id, pages_count):
fake = Faker()
Faker.seed(0)
pages = []
for _ in range(0, pages_count):
text = fake.text(max_nb_chars=60000)
pages.append(
Page(
name=fake.name(),
project=project,
workspace=workspace,
owned_by_id=user_id,
access=random.randint(0, 1),
color=fake.hex_color(),
description_html=f"<p>{text}</p>",
archived_at=None,
is_locked=False,
)
)
return Page.objects.bulk_create(pages, ignore_conflicts=True)
def create_page_labels(workspace, project, user_id, pages_count):
# labels
labels = Label.objects.filter(project=project).values_list("id", flat=True)
pages = random.sample(
list(
Page.objects.filter(project=project).values_list("id", flat=True)
),
int(pages_count / 2),
)
# Bulk page labels
bulk_page_labels = []
for page in pages:
for label in random.sample(
list(labels), random.randint(0, len(labels) - 1)
):
bulk_page_labels.append(
PageLabel(
page_id=page,
label_id=label,
project=project,
workspace=workspace,
)
)
# Page labels
PageLabel.objects.bulk_create(
bulk_page_labels, batch_size=1000, ignore_conflicts=True
)
def create_issues(workspace, project, user_id, issue_count):
fake = Faker()
Faker.seed(0)
states = State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
issues = []
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project=project,
).aggregate(
largest=Max("sequence")
)["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project=project,
state_id=states[random.randint(0, len(states) - 1)],
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
text = fake.text(max_nb_chars=60000)
issues.append(
Issue(
state_id=states[random.randint(0, len(states) - 1)],
project=project,
workspace=workspace,
name=text[:254],
description_html=f"<p>{text}</p>",
description_stripped=text,
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=start_date,
target_date=end_date,
priority=["urgent", "high", "medium", "low", "none"][
random.randint(0, 4)
],
created_by_id=creators[random.randint(0, len(creators) - 1)],
)
)
largest_sort_order = largest_sort_order + random.randint(0, 1000)
last_id = last_id + 1
issues = Issue.objects.bulk_create(
issues, ignore_conflicts=True, batch_size=1000
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project=project,
workspace=workspace,
)
for issue in issues
],
batch_size=100,
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor_id=user_id,
project=project,
workspace=workspace,
comment="created the issue",
verb="created",
created_by_id=user_id,
)
for issue in issues
],
batch_size=100,
)
return issues
def create_inbox_issues(workspace, project, user_id, inbox_issue_count):
issues = create_issues(workspace, project, user_id, inbox_issue_count)
inbox, create = Inbox.objects.get_or_create(
name="Inbox",
project=project,
is_default=True,
)
InboxIssue.objects.bulk_create(
[
InboxIssue(
issue=issue,
inbox=inbox,
status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]),
snoozed_till=(
datetime.now() + timedelta(days=random.randint(1, 30))
if status == 0
else None
),
source="in-app",
workspace=workspace,
project=project,
)
for issue in issues
],
batch_size=100,
)
def create_issue_parent(workspace, project, user_id, issue_count):
parent_count = issue_count / 4
parent_issues = Issue.objects.filter(project=project).values_list(
"id", flat=True
)[: int(parent_count)]
sub_issues = Issue.objects.filter(project=project).exclude(
pk__in=parent_issues
)[: int(issue_count / 2)]
bulk_sub_issues = []
for sub_issue in sub_issues:
sub_issue.parent_id = parent_issues[
random.randint(0, int(parent_count - 1))
]
Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000)
def create_issue_assignees(workspace, project, user_id, issue_count):
# assignees
assignees = ProjectMember.objects.filter(project=project).values_list(
"member_id", flat=True
)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_assignees = []
for issue in issues:
for assignee in random.sample(
list(assignees), random.randint(0, len(assignees) - 1)
):
bulk_issue_assignees.append(
IssueAssignee(
issue_id=issue,
assignee_id=assignee,
project=project,
workspace=workspace,
)
)
# Issue assignees
IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=1000, ignore_conflicts=True
)
def create_issue_labels(workspace, project, user_id, issue_count):
# labels
labels = Label.objects.filter(project=project).values_list("id", flat=True)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_labels = []
for issue in issues:
for label in random.sample(
list(labels), random.randint(0, len(labels) - 1)
):
bulk_issue_labels.append(
IssueLabel(
issue_id=issue,
label_id=label,
project=project,
workspace=workspace,
)
)
# Issue labels
IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=1000, ignore_conflicts=True
)
def create_cycle_issues(workspace, project, user_id, issue_count):
# assignees
cycles = Cycle.objects.filter(project=project).values_list("id", flat=True)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_cycle_issues = []
for issue in issues:
cycle = cycles[random.randint(0, len(cycles) - 1)]
bulk_cycle_issues.append(
CycleIssue(
cycle_id=cycle,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
CycleIssue.objects.bulk_create(
bulk_cycle_issues, batch_size=1000, ignore_conflicts=True
)
def create_module_issues(workspace, project, user_id, issue_count):
# assignees
modules = Module.objects.filter(project=project).values_list(
"id", flat=True
)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_module_issues = []
for issue in issues:
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
)
@shared_task
def create_dummy_data(
slug,
email,
members,
issue_count,
cycle_count,
module_count,
pages_count,
inbox_issue_count,
):
workspace = Workspace.objects.get(slug=slug)
user = User.objects.get(email=email)
user_id = user.id
# Create a project
project = create_project(workspace=workspace, user_id=user_id)
# create project members
create_project_members(
workspace=workspace, project=project, members=members
)
# Create states
create_states(workspace=workspace, project=project, user_id=user_id)
# Create labels
create_labels(workspace=workspace, project=project, user_id=user_id)
# create cycles
create_cycles(
workspace=workspace,
project=project,
user_id=user_id,
cycle_count=cycle_count,
)
# create modules
create_modules(
workspace=workspace,
project=project,
user_id=user_id,
module_count=module_count,
)
# create pages
create_pages(
workspace=workspace,
project=project,
user_id=user_id,
pages_count=pages_count,
)
# create page labels
create_page_labels(
workspace=workspace,
project=project,
user_id=user_id,
pages_count=pages_count,
)
# create issues
create_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
# create inbox issues
create_inbox_issues(
workspace=workspace,
project=project,
user_id=user_id,
inbox_issue_count=inbox_issue_count,
)
# create issue parent
create_issue_parent(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
# create issue assignees
create_issue_assignees(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
# create issue labels
create_issue_labels(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
# create cycle issues
create_cycle_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
# create module issues
create_module_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
return
@@ -1,5 +1,4 @@
import logging
import re
from datetime import datetime
from bs4 import BeautifulSoup
@@ -20,12 +19,6 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
def remove_unwanted_characters(input_text):
# Keep only alphanumeric characters, spaces, and dashes.
processed_text = re.sub(r"[^a-zA-Z0-9 \-]", "", input_text)
return processed_text
# acquire and delete redis lock
def acquire_lock(lock_id, expire_time=300):
redis_client = redis_instance()
@@ -182,16 +175,7 @@ def send_email_notification(
if acquire_lock(lock_id=lock_id):
# get the redis instance
ri = redis_instance()
base_api = (
ri.get(str(issue_id)).decode()
if ri.get(str(issue_id))
else None
)
# Skip if base api is not present
if not base_api:
return
base_api = ri.get(str(issue_id)).decode()
data = create_payload(notification_data=notification_data)
# Get email configurations
@@ -271,7 +255,9 @@ def send_email_notification(
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {remove_unwanted_characters(issue.name)}"
subject = (
f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
)
context = {
"data": template_data,
"summary": summary,
@@ -335,7 +321,8 @@ def send_email_notification(
"Duplicate email received skipping"
)
return
except (Issue.DoesNotExist, User.DoesNotExist):
except (Issue.DoesNotExist, User.DoesNotExist) as e:
log_exception(e)
release_lock(lock_id=lock_id)
return
except Exception as e:
@@ -31,7 +31,6 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name
@@ -1554,46 +1553,6 @@ def delete_draft_issue_activity(
)
def create_inbox_activity(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
):
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
status_dict = {
-2: "Pending",
-1: "Rejected",
0: "Snoozed",
1: "Accepted",
2: "Duplicate",
}
if requested_data.get("status") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="updated the inbox status",
field="inbox",
verb=requested_data.get("status"),
actor_id=actor_id,
epoch=epoch,
old_value=status_dict.get(current_instance.get("status")),
new_value=status_dict.get(requested_data.get("status")),
)
)
# Receive message from room group
@shared_task
def issue_activity(
@@ -1654,7 +1613,6 @@ def issue_activity(
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
"inbox.activity.created": create_inbox_activity,
}
func = ACTIVITY_MAPPER.get(type)
@@ -1693,19 +1651,6 @@ def issue_activity(
except Exception as e:
log_exception(e)
for activity in issue_activities_created:
webhook_activity.delay(
event="issue",
event_id=activity.issue_id,
verb=activity.verb,
field=activity.field,
old_value=activity.old_value,
new_value=activity.new_value,
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
)
if notification:
notifications.delay(
type=type,
@@ -1,76 +0,0 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
def extract_components(value, tag):
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
return mentions
except Exception:
return []
@shared_task
def page_transaction(new_value, old_value, page_id):
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value) if old_value else {}
new_transactions = []
deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
project_id=page.project_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(
new_transactions, batch_size=10, ignore_conflicts=True
)
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
+11 -168
View File
@@ -202,7 +202,16 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
return
event_data = IssueExpandSerializer(
Issue.objects.filter(
pk__in=[
str(event.get("issue")) for event in payload
]
).prefetch_related("issue_cycle", "issue_module"),
many=True,
).data
event = "issue"
action = "PATCH"
else:
event_data = [
get_model_data(
@@ -210,7 +219,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
event_id=(
payload.get("id")
if isinstance(payload, dict)
else kw.get("pk")
else None
),
many=False,
)
@@ -294,169 +303,3 @@ def send_webhook_deactivation_email(
except Exception as e:
log_exception(e)
return
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=600,
max_retries=5,
retry_jitter=True,
)
def webhook_send_task(
self,
webhook,
slug,
event,
event_data,
action,
current_site,
activity,
):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
headers = {
"Content-Type": "application/json",
"User-Agent": "Autopilot",
"X-Plane-Delivery": str(uuid.uuid4()),
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
action = {
"POST": "create",
"PATCH": "update",
"PUT": "update",
"DELETE": "delete",
}.get(action, action)
payload = {
"event": event,
"action": action,
"webhook_id": str(webhook.id),
"workspace_id": str(webhook.workspace_id),
"data": event_data,
"activity": activity,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(
webhook.url,
headers=headers,
json=payload,
timeout=30,
)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task
def webhook_activity(
event,
verb,
field,
old_value,
new_value,
actor_id,
slug,
current_site,
event_id,
):
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_send_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=get_model_data(
event=event,
event_id=event_id,
),
action=verb,
current_site=current_site,
activity={
"field": field,
"new_value": new_value,
"old_value": old_value,
"actor_id": actor_id,
},
)
@@ -1,95 +0,0 @@
# Django imports
from typing import Any
from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.db.models import User, Workspace, WorkspaceMember
class Command(BaseCommand):
help = "Create dump issues, cycles etc. for a project in a given workspace"
def handle(self, *args: Any, **options: Any) -> str | None:
try:
workspace_name = input("Workspace Name: ")
workspace_slug = input("Workspace slug: ")
if workspace_slug == "":
raise CommandError("Workspace slug is required")
if Workspace.objects.filter(slug=workspace_slug).exists():
raise CommandError("Workspace already exists")
creator = input("Your email: ")
if (
creator == ""
or not User.objects.filter(email=creator).exists()
):
raise CommandError(
"User email is required and should have signed in plane"
)
user = User.objects.get(email=creator)
members = input("Enter Member emails (comma separated): ")
members = members.split(",") if members != "" else []
# Create workspace
workspace = Workspace.objects.create(
slug=workspace_slug,
name=workspace_name,
owner=user,
)
# Create workspace member
WorkspaceMember.objects.create(
workspace=workspace, role=20, member=user
)
user_ids = User.objects.filter(email__in=members)
_ = WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace=workspace,
member=user_id,
role=20,
)
for user_id in user_ids
],
ignore_conflicts=True,
)
project_count = int(input("Number of projects to be created: "))
for i in range(project_count):
print(f"Please provide the following details for project {i+1}:")
issue_count = int(input("Number of issues to be created: "))
cycle_count = int(input("Number of cycles to be created: "))
module_count = int(input("Number of modules to be created: "))
pages_count = int(input("Number of pages to be created: "))
inbox_issue_count = int(
input("Number of inbox issues to be created: ")
)
from plane.bgtasks.dummy_data_task import create_dummy_data
create_dummy_data.delay(
slug=workspace_slug,
email=creator,
members=members,
issue_count=issue_count,
cycle_count=cycle_count,
module_count=module_count,
pages_count=pages_count,
inbox_issue_count=inbox_issue_count,
)
self.stdout.write(
self.style.SUCCESS("Data is pushed to the queue")
)
return
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Command errored out {str(e)}")
)
return
@@ -1,48 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import Instance, InstanceAdmin
from plane.db.models import User
class Command(BaseCommand):
help = "Add a new instance admin"
def add_arguments(self, parser):
# Positional argument
parser.add_argument(
"admin_email", type=str, help="Instance Admin Email"
)
def handle(self, *args, **options):
admin_email = options.get("admin_email", False)
if not admin_email:
raise CommandError("Please provide the email of the admin.")
user = User.objects.filter(email=admin_email).first()
if user is None:
raise CommandError("User with the provided email does not exist.")
try:
# Get the instance
instance = Instance.objects.last()
# Get or create an instance admin
_, created = InstanceAdmin.objects.get_or_create(
user=user, instance=instance, role=20
)
if not created:
raise CommandError(
"The provided email is already an instance admin."
)
self.stdout.write(
self.style.SUCCESS("Successfully created the admin")
)
except Exception as e:
print(e)
raise CommandError("Failed to create the instance admin.")
@@ -15,7 +15,7 @@ class Command(BaseCommand):
receiver_email = options.get("to_email")
if not receiver_email:
raise CommandError("Receiver email is required")
raise CommandError("Reciever email is required")
(
EMAIL_HOST,
@@ -54,7 +54,7 @@ class Command(BaseCommand):
connection=connection,
)
msg.send()
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
except Exception as e:
self.stdout.write(
self.style.ERROR(
@@ -1,44 +0,0 @@
# Generated by Django 4.2.10 on 2024-04-02 12:18
from django.db import migrations, models
def update_project_state_group(apps, schema_editor):
State = apps.get_model("db", "State")
# Update states in bulk
State.objects.filter(group="backlog", name="Triage").update(
is_triage=True, group="triage"
)
class Migration(migrations.Migration):
dependencies = [
("db", "0062_cycle_archived_at_module_archived_at_and_more"),
]
operations = [
migrations.AddField(
model_name="state",
name="is_triage",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="state",
name="group",
field=models.CharField(
choices=[
("backlog", "Backlog"),
("unstarted", "Unstarted"),
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage"),
],
default="backlog",
max_length=20,
),
),
migrations.RunPython(update_project_state_group),
]
@@ -1,20 +0,0 @@
# Generated by Django 4.2.10 on 2024-04-09 11:34
from django.db import migrations, models
import plane.db.models.page
class Migration(migrations.Migration):
dependencies = [
('db', '0063_state_is_triage_alter_state_group'),
]
operations = [
migrations.AddField(
model_name="page",
name="view_props",
field=models.JSONField(
default=plane.db.models.page.get_view_props
),
),
]
+2 -2
View File
@@ -171,14 +171,14 @@ class Issue(ProjectBaseModel):
from plane.db.models import State
default_state = State.objects.filter(
~models.Q(is_triage=True),
~models.Q(name="Triage"),
project=self.project,
default=True,
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
else:
+1 -6
View File
@@ -9,10 +9,6 @@ from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
def get_view_props():
return {"full_width": False}
class Page(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
@@ -39,7 +35,6 @@ class Page(ProjectBaseModel):
)
archived_at = models.DateField(null=True)
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
class Meta:
verbose_name = "Page"
@@ -86,7 +81,7 @@ class PageLog(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.page.name} {self.entity_name}"
return f"{self.page.name} {self.type}"
class PageBlock(ProjectBaseModel):
-2
View File
@@ -21,12 +21,10 @@ class State(ProjectBaseModel):
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage")
),
default="backlog",
max_length=20,
)
is_triage = models.BooleanField(default=False)
default = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
@@ -88,12 +88,6 @@ class Command(BaseCommand):
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_SSL",
"value": os.environ.get("EMAIL_USE_SSL", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "OPENAI_API_KEY",
"value": os.environ.get("OPENAI_API_KEY"),
-3
View File
@@ -288,13 +288,10 @@ else:
CELERY_BROKER_URL = REDIS_URL
CELERY_IMPORTS = (
# scheduled tasks
"plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
# Sentry Settings
+2 -2
View File
@@ -6,7 +6,7 @@ from .common import * # noqa
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DEBUG = True
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
@@ -25,7 +25,7 @@ LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
+10 -15
View File
@@ -1,18 +1,18 @@
# Python imports
from datetime import timedelta
from itertools import groupby
from datetime import timedelta
# Django import
from django.db import models
from django.db.models import Case, CharField, Count, F, Sum, Value, When
from django.utils import timezone
from django.db.models.functions import TruncDate
from django.db.models import Count, F, Sum, Value, Case, When, CharField
from django.db.models.functions import (
Coalesce,
Concat,
ExtractMonth,
ExtractYear,
TruncDate,
Concat,
)
from django.utils import timezone
# Module imports
from plane.db.models import Issue
@@ -115,16 +115,11 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
total_issues = queryset.total_issues
if cycle_id:
if queryset.end_date and queryset.start_date:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range(
(queryset.end_date - queryset.start_date).days + 1
)
]
else:
date_range = []
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range((queryset.end_date - queryset.start_date).days + 1)
]
chart_data = {str(date): 0 for date in date_range}
+24 -32
View File
@@ -33,12 +33,12 @@ def cache_response(timeout=60 * 60, path=None, user=True):
custom_path = path if path is not None else request.get_full_path()
key = generate_cache_key(custom_path, auth_header)
cached_result = cache.get(key)
if cached_result is not None:
return Response(
cached_result["data"], status=cached_result["status"]
)
response = view_func(instance, request, *args, **kwargs)
if response.status_code == 200 and not settings.DEBUG:
cache.set(
key,
@@ -53,42 +53,34 @@ def cache_response(timeout=60 * 60, path=None, user=True):
return decorator
def invalidate_cache_directly(
path=None, url_params=False, user=True, request=None, multiple=False
):
if url_params and path:
path_with_values = path
# Assuming `kwargs` could be passed directly if needed, otherwise, skip this part
for key, value in request.resolver_match.kwargs.items():
path_with_values = path_with_values.replace(f":{key}", str(value))
custom_path = path_with_values
else:
custom_path = path if path is not None else request.get_full_path()
auth_header = (
None
if request.user.is_anonymous
else str(request.user.id) if user else None
)
key = generate_cache_key(custom_path, auth_header)
def invalidate_cache(path=None, url_params=False, user=True):
"""invalidate cache per user"""
if multiple:
cache.delete_many(keys=cache.keys(f"*{key}*"))
else:
cache.delete(key)
def invalidate_cache(path=None, url_params=False, user=True, multiple=False):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs):
# invalidate the cache
invalidate_cache_directly(
path=path,
url_params=url_params,
user=user,
request=request,
multiple=multiple,
# Invalidate cache before executing the view function
if url_params:
path_with_values = path
for key, value in kwargs.items():
path_with_values = path_with_values.replace(
f":{key}", str(value)
)
custom_path = path_with_values
else:
custom_path = (
path if path is not None else request.get_full_path()
)
auth_header = (
None
if request.user.is_anonymous
else str(request.user.id) if user else None
)
key = generate_cache_key(custom_path, auth_header)
cache.delete(key)
# Execute the view function
return view_func(instance, request, *args, **kwargs)
return _wrapped_view
+72 -85
View File
@@ -52,9 +52,9 @@ def string_date_filter(
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now + timedelta(days=duration)
else:
filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now - timedelta(days=duration)
def date_filter(filter, date_term, queries):
@@ -83,25 +83,25 @@ def date_filter(filter, date_term, queries):
filter[f"{date_term}__lte"] = date_query[0]
def filter_state(params, filter, method, prefix=""):
def filter_state(params, filter, method):
if method == "GET":
states = [
item for item in params.get("state").split(",") if item != "null"
]
states = filter_valid_uuids(states)
if len(states) and "" not in states:
filter[f"{prefix}state__in"] = states
filter["state__in"] = states
else:
if (
params.get("state", None)
and len(params.get("state"))
and params.get("state") != "null"
):
filter[f"{prefix}state__in"] = params.get("state")
filter["state__in"] = params.get("state")
return filter
def filter_state_group(params, filter, method, prefix=""):
def filter_state_group(params, filter, method):
if method == "GET":
state_group = [
item
@@ -109,18 +109,18 @@ def filter_state_group(params, filter, method, prefix=""):
if item != "null"
]
if len(state_group) and "" not in state_group:
filter[f"{prefix}state__group__in"] = state_group
filter["state__group__in"] = state_group
else:
if (
params.get("state_group", None)
and len(params.get("state_group"))
and params.get("state_group") != "null"
):
filter[f"{prefix}state__group__in"] = params.get("state_group")
filter["state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, filter, method, prefix=""):
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = [
item
@@ -128,20 +128,18 @@ def filter_estimate_point(params, filter, method, prefix=""):
if item != "null"
]
if len(estimate_points) and "" not in estimate_points:
filter[f"{prefix}estimate_point__in"] = estimate_points
filter["estimate_point__in"] = estimate_points
else:
if (
params.get("estimate_point", None)
and len(params.get("estimate_point"))
and params.get("estimate_point") != "null"
):
filter[f"{prefix}estimate_point__in"] = params.get(
"estimate_point"
)
filter["estimate_point__in"] = params.get("estimate_point")
return filter
def filter_priority(params, filter, method, prefix=""):
def filter_priority(params, filter, method):
if method == "GET":
priorities = [
item
@@ -149,47 +147,47 @@ def filter_priority(params, filter, method, prefix=""):
if item != "null"
]
if len(priorities) and "" not in priorities:
filter[f"{prefix}priority__in"] = priorities
filter["priority__in"] = priorities
return filter
def filter_parent(params, filter, method, prefix=""):
def filter_parent(params, filter, method):
if method == "GET":
parents = [
item for item in params.get("parent").split(",") if item != "null"
]
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
filter[f"{prefix}parent__in"] = parents
filter["parent__in"] = parents
else:
if (
params.get("parent", None)
and len(params.get("parent"))
and params.get("parent") != "null"
):
filter[f"{prefix}parent__in"] = params.get("parent")
filter["parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method, prefix=""):
def filter_labels(params, filter, method):
if method == "GET":
labels = [
item for item in params.get("labels").split(",") if item != "null"
]
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
filter[f"{prefix}labels__in"] = labels
filter["labels__in"] = labels
else:
if (
params.get("labels", None)
and len(params.get("labels"))
and params.get("labels") != "null"
):
filter[f"{prefix}labels__in"] = params.get("labels")
filter["labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method, prefix=""):
def filter_assignees(params, filter, method):
if method == "GET":
assignees = [
item
@@ -198,18 +196,18 @@ def filter_assignees(params, filter, method, prefix=""):
]
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
filter[f"{prefix}assignees__in"] = assignees
filter["assignees__in"] = assignees
else:
if (
params.get("assignees", None)
and len(params.get("assignees"))
and params.get("assignees") != "null"
):
filter[f"{prefix}assignees__in"] = params.get("assignees")
filter["assignees__in"] = params.get("assignees")
return filter
def filter_mentions(params, filter, method, prefix=""):
def filter_mentions(params, filter, method):
if method == "GET":
mentions = [
item
@@ -218,20 +216,18 @@ def filter_mentions(params, filter, method, prefix=""):
]
mentions = filter_valid_uuids(mentions)
if len(mentions) and "" not in mentions:
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
filter["issue_mention__mention__id__in"] = mentions
else:
if (
params.get("mentions", None)
and len(params.get("mentions"))
and params.get("mentions") != "null"
):
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
"mentions"
)
filter["issue_mention__mention__id__in"] = params.get("mentions")
return filter
def filter_created_by(params, filter, method, prefix=""):
def filter_created_by(params, filter, method):
if method == "GET":
created_bys = [
item
@@ -240,98 +236,94 @@ def filter_created_by(params, filter, method, prefix=""):
]
created_bys = filter_valid_uuids(created_bys)
if len(created_bys) and "" not in created_bys:
filter[f"{prefix}created_by__in"] = created_bys
filter["created_by__in"] = created_bys
else:
if (
params.get("created_by", None)
and len(params.get("created_by"))
and params.get("created_by") != "null"
):
filter[f"{prefix}created_by__in"] = params.get("created_by")
filter["created_by__in"] = params.get("created_by")
return filter
def filter_name(params, filter, method, prefix=""):
def filter_name(params, filter, method):
if params.get("name", "") != "":
filter[f"{prefix}name__icontains"] = params.get("name")
filter["name__icontains"] = params.get("name")
return filter
def filter_created_at(params, filter, method, prefix=""):
def filter_created_at(params, filter, method):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
date_filter(
filter=filter,
date_term=f"{prefix}created_at__date",
date_term="created_at__date",
queries=created_ats,
)
else:
if params.get("created_at", None) and len(params.get("created_at")):
date_filter(
filter=filter,
date_term=f"{prefix}created_at__date",
date_term="created_at__date",
queries=params.get("created_at", []),
)
return filter
def filter_updated_at(params, filter, method, prefix=""):
def filter_updated_at(params, filter, method):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
date_filter(
filter=filter,
date_term=f"{prefix}created_at__date",
date_term="created_at__date",
queries=updated_ats,
)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
date_filter(
filter=filter,
date_term=f"{prefix}created_at__date",
date_term="created_at__date",
queries=params.get("updated_at", []),
)
return filter
def filter_start_date(params, filter, method, prefix=""):
def filter_start_date(params, filter, method):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
date_filter(
filter=filter,
date_term=f"{prefix}start_date",
queries=start_dates,
filter=filter, date_term="start_date", queries=start_dates
)
else:
if params.get("start_date", None) and len(params.get("start_date")):
filter[f"{prefix}start_date"] = params.get("start_date")
filter["start_date"] = params.get("start_date")
return filter
def filter_target_date(params, filter, method, prefix=""):
def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(
filter=filter,
date_term=f"{prefix}target_date",
queries=target_dates,
filter=filter, date_term="target_date", queries=target_dates
)
else:
if params.get("target_date", None) and len(params.get("target_date")):
filter[f"{prefix}target_date"] = params.get("target_date")
filter["target_date"] = params.get("target_date")
return filter
def filter_completed_at(params, filter, method, prefix=""):
def filter_completed_at(params, filter, method):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
date_filter(
filter=filter,
date_term=f"{prefix}completed_at__date",
date_term="completed_at__date",
queries=completed_ats,
)
else:
@@ -340,13 +332,13 @@ def filter_completed_at(params, filter, method, prefix=""):
):
date_filter(
filter=filter,
date_term=f"{prefix}completed_at__date",
date_term="completed_at__date",
queries=params.get("completed_at", []),
)
return filter
def filter_issue_state_type(params, filter, method, prefix=""):
def filter_issue_state_type(params, filter, method):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
@@ -354,67 +346,65 @@ def filter_issue_state_type(params, filter, method, prefix=""):
if type == "active":
group = ["unstarted", "started"]
filter[f"{prefix}state__group__in"] = group
filter["state__group__in"] = group
return filter
def filter_project(params, filter, method, prefix=""):
def filter_project(params, filter, method):
if method == "GET":
projects = [
item for item in params.get("project").split(",") if item != "null"
]
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
filter[f"{prefix}project__in"] = projects
filter["project__in"] = projects
else:
if (
params.get("project", None)
and len(params.get("project"))
and params.get("project") != "null"
):
filter[f"{prefix}project__in"] = params.get("project")
filter["project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method, prefix=""):
def filter_cycle(params, filter, method):
if method == "GET":
cycles = [
item for item in params.get("cycle").split(",") if item != "null"
]
cycles = filter_valid_uuids(cycles)
if len(cycles) and "" not in cycles:
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
filter["issue_cycle__cycle_id__in"] = cycles
else:
if (
params.get("cycle", None)
and len(params.get("cycle"))
and params.get("cycle") != "null"
):
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, filter, method, prefix=""):
def filter_module(params, filter, method):
if method == "GET":
modules = [
item for item in params.get("module").split(",") if item != "null"
]
modules = filter_valid_uuids(modules)
if len(modules) and "" not in modules:
filter[f"{prefix}issue_module__module_id__in"] = modules
filter["issue_module__module_id__in"] = modules
else:
if (
params.get("module", None)
and len(params.get("module"))
and params.get("module") != "null"
):
filter[f"{prefix}issue_module__module_id__in"] = params.get(
"module"
)
filter["issue_module__module_id__in"] = params.get("module")
return filter
def filter_inbox_status(params, filter, method, prefix=""):
def filter_inbox_status(params, filter, method):
if method == "GET":
status = [
item
@@ -422,32 +412,30 @@ def filter_inbox_status(params, filter, method, prefix=""):
if item != "null"
]
if len(status) and "" not in status:
filter[f"{prefix}issue_inbox__status__in"] = status
filter["issue_inbox__status__in"] = status
else:
if (
params.get("inbox_status", None)
and len(params.get("inbox_status"))
and params.get("inbox_status") != "null"
):
filter[f"{prefix}issue_inbox__status__in"] = params.get(
"inbox_status"
)
filter["issue_inbox__status__in"] = params.get("inbox_status")
return filter
def filter_sub_issue_toggle(params, filter, method, prefix=""):
def filter_sub_issue_toggle(params, filter, method):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter[f"{prefix}parent__isnull"] = True
filter["parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter[f"{prefix}parent__isnull"] = True
filter["parent__isnull"] = True
return filter
def filter_subscribed_issues(params, filter, method, prefix=""):
def filter_subscribed_issues(params, filter, method):
if method == "GET":
subscribers = [
item
@@ -456,30 +444,28 @@ def filter_subscribed_issues(params, filter, method, prefix=""):
]
subscribers = filter_valid_uuids(subscribers)
if len(subscribers) and "" not in subscribers:
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
subscribers
)
filter["issue_subscribers__subscriber_id__in"] = subscribers
else:
if (
params.get("subscriber", None)
and len(params.get("subscriber"))
and params.get("subscriber") != "null"
):
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
params.get("subscriber")
filter["issue_subscribers__subscriber_id__in"] = params.get(
"subscriber"
)
return filter
def filter_start_target_date_issues(params, filter, method, prefix=""):
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter[f"{prefix}target_date__isnull"] = False
filter[f"{prefix}start_date__isnull"] = False
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method, prefix=""):
def issue_filters(query_params, method):
filter = {}
ISSUE_FILTER = {
@@ -511,5 +497,6 @@ def issue_filters(query_params, method, prefix=""):
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, filter, method, prefix)
func(query_params, filter, method)
return filter
+1 -2
View File
@@ -134,7 +134,7 @@ class OffsetPaginator:
results=results,
next=next_cursor,
prev=prev_cursor,
hits=count,
hits=None,
max_hits=max_hits,
)
@@ -217,7 +217,6 @@ class BasePaginator:
"prev_page_results": cursor_result.prev.has_results,
"count": cursor_result.__len__(),
"total_pages": cursor_result.max_hits,
"total_results": cursor_result.hits,
"extra_stats": extra_stats,
"results": results,
}
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
gunicorn==22.0.0
gunicorn==21.2.0
+1 -1
View File
@@ -1 +1 @@
python-3.11.9
python-3.11.8
+1 -1
View File
@@ -180,7 +180,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:7.2.4-alpine
image: redis:6.2.7-alpine
restart: always
volumes:
- redisdata:/data
+2
View File
@@ -1,3 +1,5 @@
version: "3.8"
x-app-env: &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-80}
+3 -1
View File
@@ -1,3 +1,5 @@
version: "3.8"
networks:
dev_env:
driver: bridge
@@ -10,7 +12,7 @@ volumes:
services:
plane-redis:
image: redis:7.2.4-alpine
image: redis:6.2.7-alpine
restart: unless-stopped
networks:
- dev_env
+3 -1
View File
@@ -1,3 +1,5 @@
version: "3.8"
services:
web:
container_name: web
@@ -90,7 +92,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:7.2.4-alpine
image: redis:6.2.7-alpine
restart: always
volumes:
- redisdata:/data
+2 -2
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.18.0",
"version": "0.17.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -28,7 +28,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.13.2"
"turbo": "^1.11.3"
},
"resolutions": {
"@types/react": "18.2.42"
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.18.0",
"version": "0.17.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -28,13 +28,13 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code-block-lowlight": "^2.1.13",
"@tiptap/extension-color": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-style": "^2.1.13",
@@ -50,7 +50,6 @@
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.294.0",
"prosemirror-codemark": "^0.4.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
@@ -7,22 +7,11 @@ export const insertContentAtSavedSelection = (
content: string,
savedSelection: Selection
) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
if (!savedSelection) {
console.error("Saved selection is invalid.");
return;
}
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
try {
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
} catch (error) {
console.error("An error occurred while inserting content at saved selection:", error);
if (editorRef.current && savedSelection) {
editorRef.current
.chain()
.focus()
.insertContentAt(savedSelection?.anchor, content)
.run();
}
};
@@ -1,40 +0,0 @@
import { Editor } from "@tiptap/react";
export interface IMarking {
type: "heading";
level: number;
text: string;
sequence: number;
}
function findNthH1(editor: Editor, n: number, level: number): number {
let count = 0;
let pos = 0;
editor.state.doc.descendants((node, position) => {
if (node.type.name === "heading" && node.attrs.level === level) {
count++;
if (count === n) {
pos = position;
return false;
}
}
});
return pos;
}
function scrollToNode(editor: Editor, pos: number): void {
const headingNode = editor.state.doc.nodeAt(pos);
if (headingNode) {
const headingDOM = editor.view.nodeDOM(pos);
if (headingDOM instanceof HTMLElement) {
headingDOM.scrollIntoView({ behavior: "smooth" });
}
}
}
export function scrollSummary(editor: Editor, marking: IMarking) {
if (editor) {
const pos = findNthH1(editor, marking.sequence, marking.level);
scrollToNode(editor, pos);
}
}
+65 -169
View File
@@ -1,215 +1,111 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { useImperativeHandle, useRef, MutableRefObject, useState } from "react";
import { CoreEditorProps } from "src/ui/props";
import { CoreEditorExtensions } from "src/ui/extensions";
import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "src/lib/utils";
import { DeleteImage } from "src/types/delete-image";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position";
import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items";
import { EditorRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
interface CustomEditorProps {
id?: string;
uploadFile: UploadImage;
restoreFile: RestoreImage;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
deleteFile: DeleteImage;
cancelUploadImage?: () => void;
initialValue: string;
editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value: string | null | undefined;
onChange?: (json: object, html: string) => void;
cancelUploadImage?: () => any;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
debouncedUpdatesEnabled?: boolean;
onStart?: (json: any, html: string) => void;
onChange?: (json: any, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
export const useEditor = ({
uploadFile,
id = "",
deleteFile,
cancelUploadImage,
editorProps = {},
initialValue,
editorClassName,
value,
rerenderOnPropsChange,
extensions = [],
onStart,
onChange,
setIsSubmitting,
forwardedRef,
tabIndex,
restoreFile,
handleEditorReady,
mentionHandler,
placeholder,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions,
}: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(editorClassName),
...editorProps,
},
extensions: [
...CoreEditorExtensions({
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
fileConfig: {
const editor = useCustomEditor(
{
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
restoreFile,
cancelUploadImage,
uploadFile,
},
placeholder,
tabIndex,
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: async () => {
handleEditorReady?.(true);
cancelUploadImage
),
...extensions,
],
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onCreate: async ({ editor }) => {
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onTransaction: async ({ editor }) => {
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
},
onTransaction: async ({ editor }) => {
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onDestroy: async () => {
handleEditorReady?.(false);
},
});
[rerenderOnPropsChange]
);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// Inside your component or hook
const savedSelectionRef = useRef(savedSelection);
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
}, [savedSelection]);
// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
try {
editor.commands.setContent(value);
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
console.error("Error syncing editor content with external value:", error);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
}
}, [editor, value, id]);
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
if (item) {
if (item.key === "image") {
item.command(savedSelection);
} else {
item.command();
}
} else {
console.warn(`No command found for item: ${itemName}`);
}
},
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
callback();
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editorRef.current
.chain()
.insertContentAt(safePosition, [{ type: "paragraph" }])
.focus()
.run();
} catch (error) {
console.error("An error occurred while setting focus at position:", error);
}
},
}),
[editorRef, savedSelection, uploadFile]
);
},
}));
if (!editor) {
return null;
}
// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;
return editor;
};
@@ -1,61 +1,53 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { EditorReadOnlyRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { IMentionSuggestion } from "src/types/mention-suggestion";
interface CustomReadOnlyEditorProps {
initialValue: string;
editorClassName: string;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
value: string;
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
export const useReadOnlyEditor = ({
initialValue,
editorClassName,
value,
forwardedRef,
extensions = [],
editorProps = {},
handleEditorReady,
mentionHandler,
rerenderOnPropsChange,
mentionHighlights,
mentionSuggestions,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps(editorClassName),
...editorProps,
const editor = useCustomEditor(
{
editable: false,
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
}),
...extensions,
],
},
onCreate: async () => {
handleEditorReady?.(true);
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionHighlights: mentionHandler.highlights,
}),
...extensions,
],
onDestroy: () => {
handleEditorReady?.(false);
},
});
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
}, [editor, initialValue]);
[rerenderOnPropsChange]
);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
@@ -64,20 +56,11 @@ export const useReadOnlyEditor = ({
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
}));
if (!editor) {
return null;
}
editorRef.current = editor;
return editor;
};
-1
View File
@@ -26,7 +26,6 @@ export * from "src/lib/editor-commands";
// types
export type { DeleteImage } from "src/types/delete-image";
export type { UploadImage } from "src/types/upload-image";
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
export type { RestoreImage } from "src/types/restore-image";
export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion";
+42 -115
View File
@@ -1,42 +1,21 @@
import { Editor, Range } from "@tiptap/core";
import { startImageUpload } from "src/ui/plugins/upload-image";
import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
else editor.chain().focus().clearNodes().run();
};
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
else editor.chain().focus().toggleHeading({ level: 1 }).run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run();
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
else editor.chain().focus().toggleHeading({ level: 2 }).run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 2 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run();
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
else editor.chain().focus().toggleHeading({ level: 4 }).run();
};
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
else editor.chain().focus().toggleHeading({ level: 5 }).run();
};
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
else editor.chain().focus().toggleHeading({ level: 6 }).run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 3 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
@@ -54,98 +33,48 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleUnderline().run();
};
const replaceCodeBlockWithContent = (editor: Editor) => {
try {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
let replaced = false;
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// split the textContent by new lines to handle each line as a separate paragraph
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
// Calculate the position for inserting the first paragraph
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
const paragraphNode = paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
// Update insertPos for the next insertion
insertPos += paragraphNode.nodeSize;
});
// Dispatch the transaction
editor.view.dispatch(tr);
replaced = true;
};
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
if (node.type === schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
replaceCodeBlock(startPos, endPos, textContent);
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
};
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
try {
if (editor.isActive("codeBlock")) {
replaceCodeBlockWithContent(editor);
// Check if code block is active then toggle code block
if (editor.isActive("codeBlock")) {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
return;
}
editor.chain().focus().clearNodes().toggleCodeBlock().run();
return;
}
const { from, to } = range || editor.state.selection;
const text = editor.state.doc.textBetween(from, to, "\n");
const isMultiline = text.includes("\n");
// Check if user hasn't selected any text
const isSelectionEmpty = editor.state.selection.empty;
if (editor.state.selection.empty) {
editor.chain().focus().toggleCodeBlock().run();
} else if (isMultiline) {
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
} else {
editor.chain().focus().toggleCode().run();
if (isSelectionEmpty) {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
return;
}
} catch (error) {
console.error("An error occurred while toggling code block:", error);
editor.chain().focus().clearNodes().toggleCodeBlock().run();
} else {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run();
return;
}
editor.chain().focus().clearNodes().toggleCode().run();
}
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
else editor.chain().focus().toggleOrderedList().run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run();
else editor.chain().focus().clearNodes().toggleOrderedList().run();
};
export const toggleBulletList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
else editor.chain().focus().toggleBulletList().run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBulletList().run();
else editor.chain().focus().clearNodes().toggleBulletList().run();
};
export const toggleTaskList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleTaskList().run();
else editor.chain().focus().clearNodes().toggleTaskList().run();
};
export const toggleStrike = (editor: Editor, range?: Range) => {
@@ -154,19 +83,17 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
};
export const toggleBlockquote = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
else editor.chain().focus().toggleBlockquote().run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run();
else editor.chain().focus().clearNodes().toggleBlockquote().run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (typeof window !== "undefined") {
const selection = window.getSelection();
if (selection) {
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
@@ -185,7 +112,7 @@ export const setLinkEditor = (editor: Editor, url: string) => {
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
savedSelection?: Selection | null,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
range?: Range
) => {
if (range) editor.chain().focus().deleteRange(range).run();
@@ -195,8 +122,8 @@ export const insertImageCommand = (
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
startImageUpload(editor, file, editor.view, pos, uploadFile);
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
}
};
input.click();
+6 -8
View File
@@ -4,17 +4,15 @@ import { twMerge } from "tailwind-merge";
interface EditorClassNames {
noBorder?: boolean;
borderOnFocus?: boolean;
containerClassName?: string;
customClassName?: string;
}
export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: EditorClassNames) =>
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) =>
cn(
"w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0",
{
"border border-custom-border-200": !noBorder,
"focus:border border-custom-border-300": borderOnFocus,
},
containerClassName
"relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md",
noBorder ? "" : "border border-custom-border-200",
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0",
customClassName
);
export function cn(...inputs: ClassValue[]) {
+25 -182
View File
@@ -7,17 +7,10 @@
}
/* block quotes */
.ProseMirror blockquote {
font-style: normal;
font-weight: 400;
border-left: 3px solid rgb(var(--color-border-300));
}
.ProseMirror blockquote p::before,
.ProseMirror blockquote p::after {
display: none;
}
/* end block quotes */
.ProseMirror code::before,
.ProseMirror code::after {
@@ -35,8 +28,8 @@
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
margin-top: 8px;
margin-bottom: 0;
margin-top: 0 !important;
margin-bottom: 0 !important;
&:hover {
cursor: pointer;
@@ -44,49 +37,22 @@
}
&.ProseMirror-selectednode {
outline: 3px solid rgba(var(--color-primary-100));
outline: 3px solid #5abbf7;
filter: brightness(90%);
}
}
/* Custom gap cursor styles */
.ProseMirror-gapcursor::after {
.ProseMirror-gapcursor:after {
border-top: 1px solid rgb(var(--color-text-100)) !important;
}
/* to-do list */
ul[data-type="taskList"] li {
font-size: 1rem;
line-height: 1.5;
}
/* Custom TODO list checkboxes shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
ul[data-type="taskList"] li > label {
margin: 0.1rem 0.15rem 0 0;
margin-right: 0.2rem;
user-select: none;
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
border: 1px solid rgba(var(--color-border-300)) !important;
outline: none;
border-radius: 2px;
transform: scale(1.05);
}
ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
background-color: rgba(var(--color-background-80)) !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
background-color: rgba(var(--color-primary-100)) !important;
border-color: rgba(var(--color-primary-100)) !important;
color: white !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
background-color: rgba(var(--color-primary-300)) !important;
border-color: rgba(var(--color-primary-300)) !important;
}
@media screen and (max-width: 768px) {
ul[data-type="taskList"] li > label {
margin-right: 0.5rem;
@@ -94,7 +60,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
@@ -106,6 +71,8 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
border: 1.5px solid rgb(var(--color-text-100));
margin-right: 0.2rem;
margin-top: 0.15rem;
display: grid;
place-content: center;
&:hover {
background-color: rgb(var(--color-background-80));
@@ -115,32 +82,27 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
background-color: rgb(var(--color-background-90));
}
/* check sign */
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0.5em;
height: 0.5em;
transform: scale(0);
transform-origin: center;
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em;
transform-origin: center;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
transform: scale(1) translate(-50%, -50%);
transform: scale(1);
}
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-400));
color: rgb(var(--color-text-200));
text-decoration: line-through;
text-decoration-thickness: 2px;
}
/* end to-do list */
/* Overwrite tippy-box original max-width */
@@ -171,12 +133,12 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
-moz-appearance: textfield;
}
.fade-in {
.fadeIn {
opacity: 1;
transition: opacity 0.3s ease-in;
}
.fade-out {
.fadeOut {
opacity: 0;
transition: opacity 0.2s ease-out;
}
@@ -187,7 +149,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
margin-top: 0 !important;
margin-bottom: 0 !important;
&::before {
&:before {
content: "";
box-sizing: border-box;
position: absolute;
@@ -213,13 +175,21 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
cursor: col-resize;
}
.ProseMirror table * p {
padding: 0px 1px;
margin: 6px 2px;
}
.ProseMirror table * .is-empty::before {
opacity: 0;
}
.ProseMirror pre {
font-family: JetBrainsMono, monospace;
tab-size: 2;
background: rgba(var(--color-background-80));
border-radius: 0.5rem;
color: rgba(var(--color-text-100));
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
}
.ProseMirror pre code {
@@ -236,7 +206,7 @@ div[data-type="horizontalRule"] {
margin-bottom: 0;
& > div {
border-bottom: 2px solid rgb(var(--color-border-200));
border-bottom: 1px solid rgb(var(--color-text-100));
}
}
@@ -244,130 +214,3 @@ div[data-type="horizontalRule"] {
.moveable-control-box {
z-index: 10 !important;
}
/* Cursor styles for the inline code blocks */
@keyframes blink {
49% {
border-color: unset;
}
50% {
border-color: transparent;
}
99% {
border-color: transparent;
}
}
.no-cursor {
caret-color: transparent;
}
div:focus .fake-cursor,
span:focus .fake-cursor {
margin-right: -1px;
border-left-width: 1.5px;
border-left-style: solid;
animation: blink 1s;
animation-iteration-count: infinite;
position: relative;
z-index: 1;
}
/* numbered, bulleted and to-do lists spacing */
.prose ol:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
.prose
ul:not([data-type="taskList"]):where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
.prose ul[data-type="taskList"]:where(.prose > :first-child) {
margin-top: 0.25rem !important;
margin-bottom: 1px !important;
}
.prose ol:not(:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))),
.prose
ul:not([data-type="taskList"]):not(
:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))
),
.prose ul[data-type="taskList"]:not(:where(.prose > :first-child)) {
margin-top: calc(0.25rem + 3px) !important;
margin-bottom: 1px !important;
}
ol ol,
ol ul:not([data-type="taskList"]),
ul:not([data-type="taskList"]) ul:not([data-type="taskList"]),
ul:not([data-type="taskList"]) ol {
margin-top: 0.45rem !important;
}
ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 0.6rem;
}
/* end numbered, bulleted and to-do lists spacing */
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem;
margin-bottom: 4px;
font-size: 1.875rem;
font-weight: 700;
line-height: 1.3;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1.25rem;
line-height: 1.3;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1rem;
line-height: 1.5;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 0.9rem;
font-weight: 600;
line-height: 1.5;
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 0.83rem;
font-weight: 600;
line-height: 1.5;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem;
margin-bottom: 1px;
padding: 3px 2px;
font-size: 1rem;
line-height: 1.5;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: 1rem;
line-height: 1.5;
}
.prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0;
}
/* end tailwind typography */
+164 -40
View File
@@ -1,25 +1,23 @@
.table-wrapper {
.tableWrapper {
overflow-x: auto;
padding: 2px;
width: fit-content;
max-width: 100%;
}
.table-wrapper table {
.tableWrapper table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 1rem 0;
border: 1px solid rgba(var(--color-border-200));
margin: 0;
margin-bottom: 1rem;
border: 2px solid rgba(var(--color-border-300));
width: 100%;
}
.table-wrapper table p {
font-size: 14px;
}
.table-wrapper table td,
.table-wrapper table th {
.tableWrapper table td,
.tableWrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-200));
border: 1px solid rgba(var(--color-border-300));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
@@ -31,34 +29,86 @@
}
}
.table-wrapper table th {
font-weight: 500;
.tableWrapper table td > *,
.tableWrapper table th > * {
margin: 0 !important;
padding: 0.25rem 0 !important;
}
.tableWrapper table td.has-focus,
.tableWrapper table th.has-focus {
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
}
.tableWrapper table th {
font-weight: bold;
text-align: left;
background-color: rgba(var(--color-background-90));
background-color: #d9e4ff;
color: #171717;
}
.table-wrapper table .selectedCell {
outline: 0.5px solid rgba(var(--color-primary-100));
.tableWrapper table th * {
font-weight: 600;
}
/* table dropdown */
.table-wrapper table .column-resize-handle {
.tableWrapper table .selectedCell:after {
z-index: 2;
position: absolute;
right: -2px;
content: "";
left: 0;
right: 0;
top: 0;
width: 2px;
height: 100%;
z-index: 5;
background-color: rgba(var(--color-primary-100));
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.table-wrapper .table-controls {
.colorPicker {
display: grid;
padding: 8px 8px;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.colorPickerLabel {
font-size: 0.85rem;
color: #6b7280;
padding: 8px 8px;
padding-bottom: 0px;
}
.colorPickerItem {
margin: 2px 0px;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.divider {
background-color: #e5e7eb;
height: 1px;
margin: 3px 0;
}
.tableWrapper table .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
z-index: 5;
background-color: #d9e4ff;
pointer-events: none;
}
.tableWrapper .tableControls {
position: absolute;
}
.table-wrapper .table-controls .columns-control,
.table-wrapper .table-controls .rows-control {
.tableWrapper .tableControls .columnsControl,
.tableWrapper .tableControls .rowsControl {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
@@ -67,35 +117,34 @@
align-items: center;
}
.table-wrapper .table-controls .columns-control {
.tableWrapper .tableControls .columnsControl {
height: 20px;
transform: translateY(-50%);
}
.table-wrapper .table-controls .columns-control .columns-control-div {
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
.table-wrapper .table-controls .rows-control {
.tableWrapper .tableControls .rowsControl {
width: 20px;
transform: translateX(-50%);
}
.table-wrapper .table-controls .rows-control .rows-control-div {
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.table-wrapper .table-controls .rows-control-div,
.table-wrapper .table-controls .columns-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
.tableWrapper .tableControls .rowsControlDiv {
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
@@ -103,14 +152,89 @@
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.resize-cursor .table-wrapper .table-controls .rows-control,
.table-wrapper.controls--disabled .table-controls .rows-control,
.resize-cursor .table-wrapper .table-controls .columns-control,
.table-wrapper.controls--disabled .table-controls .columns-control {
.tableWrapper .tableControls .columnsControlDiv {
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));
background-color: rgba(var(--color-background-100));
border-radius: 5px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
display: flex;
flex-direction: column;
width: max-content;
gap: 0.25rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem {
background-color: rgba(var(--color-background-100));
display: flex;
align-items: center;
gap: 0.5rem;
border: none;
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
background-color: rgba(var(--color-background-80), 0.6);
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
padding: 4px 0px;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
width: 1rem;
height: 1rem;
}
.tableToolbox {
background-color: rgba(var(--color-background-100));
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .label,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label {
font-size: 0.85rem;
color: rgba(var(--color-text-300));
}
.resize-cursor .tableWrapper .tableControls .rowsControl,
.tableWrapper.controls--disabled .tableControls .rowsControl,
.resize-cursor .tableWrapper .tableControls .columnsControl,
.tableWrapper.controls--disabled .tableControls .columnsControl {
opacity: 0;
pointer-events: none;
}
@@ -1,17 +0,0 @@
import { IMarking } from "src/helpers/scroll-to-node";
import { EditorMenuItemNames } from "src/ui/menus/menu-items";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
clearEditor: () => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
}
@@ -1,18 +1,10 @@
import { Editor, Range } from "@tiptap/react";
export type IMentionSuggestion = {
id: string;
type: string;
entity_name: string;
entity_identifier: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type CommandProps = {
editor: Editor;
range: Range;
};
export type IMentionHighlight = string;
@@ -4,65 +4,57 @@ import { cn } from "src/lib/utils";
interface EditorContainerProps {
editor: Editor | null;
editorContainerClassName: string;
editorClassNames: string;
children: ReactNode;
hideDragHandle?: () => void;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorContainerClassName, hideDragHandle, children } = props;
const { editor, editorClassNames, hideDragHandle, children } = props;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
try {
if (editor.isFocused) return; // If editor is already focused, do nothing
if (editor.isFocused) return; // If editor is already focused, do nothing
const { selection } = editor.state;
const currentNode = selection.$from.node();
const { selection } = editor.state;
const currentNode = selection.$from.node();
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
};
return (
<div
id="editor-container"
onClick={handleContainerClick}
onMouseLeave={hideDragHandle}
className={cn(
"cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
editorContainerClassName
)}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={cn(`cursor-text`, { "active-editor": editor?.isFocused && editor?.isEditable }, editorClassNames)}
>
{children}
</div>
@@ -4,15 +4,22 @@ import { ImageResizer } from "src/ui/extensions/image/image-resize";
interface EditorContentProps {
editor: Editor | null;
editorContentCustomClassNames: string | undefined;
children?: ReactNode;
tabIndex?: number;
}
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, tabIndex, children } = props;
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<div
className={`contentEditor ${editorContentCustomClassNames}`}
tabIndex={tabIndex}
onFocus={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children}
@@ -32,8 +32,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
addOptions() {
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
};

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