Compare commits

..

1 Commits

Author SHA1 Message Date
Palanikannan M 6bdbf4e536 fix: speed variability adjusted 2024-12-30 16:52:30 +05:30
1261 changed files with 19696 additions and 41505 deletions
+126
View File
@@ -0,0 +1,126 @@
name: "Build and Push Docker Image"
description: "Reusable action for building and pushing Docker images"
inputs:
docker-username:
description: "The Dockerhub username"
required: true
docker-token:
description: "The Dockerhub Token"
required: true
# Docker Image Options
docker-image-owner:
description: "The owner of the Docker image"
required: true
docker-image-name:
description: "The name of the Docker image"
required: true
build-context:
description: "The build context"
required: true
default: "."
dockerfile-path:
description: "The path to the Dockerfile"
required: true
build-args:
description: "The build arguments"
required: false
default: ""
# Buildx Options
buildx-driver:
description: "Buildx driver"
required: true
default: "docker-container"
buildx-version:
description: "Buildx version"
required: true
default: "latest"
buildx-platforms:
description: "Buildx platforms"
required: true
default: "linux/amd64"
buildx-endpoint:
description: "Buildx endpoint"
required: true
default: "default"
# Release Build Options
build-release:
description: "Flag to publish release"
required: false
default: "false"
build-prerelease:
description: "Flag to publish prerelease"
required: false
default: "false"
release-version:
description: "The release version"
required: false
default: "latest"
runs:
using: "composite"
steps:
- name: Set Docker Tag
shell: bash
env:
IMG_OWNER: ${{ inputs.docker-image-owner }}
IMG_NAME: ${{ inputs.docker-image-name }}
BUILD_RELEASE: ${{ inputs.build-release }}
IS_PRERELEASE: ${{ inputs.build-prerelease }}
REL_VERSION: ${{ inputs.release-version }}
run: |
FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g')
if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then
echo "Invalid Release Version Format : ${{ env.REL_VERSION }}"
echo "Please provide a valid SemVer version"
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
echo "Exiting the build process"
exit 1 # Exit with status 1 to fail the step
fi
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest
else
TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION}
fi
echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-token}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ inputs.buildx-driver }}
version: ${{ inputs.buildx-version }}
endpoint: ${{ inputs.buildx-endpoint }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Docker Image
uses: docker/build-push-action@v5.1.0
with:
context: ${{ inputs.build-context }}
file: ${{ inputs.dockerfile-path }}
platforms: ${{ inputs.buildx-platforms }}
tags: ${{ env.DOCKER_TAGS }}
push: true
build-args: ${{ inputs.build-args }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ inputs.docker-username }}
DOCKER_PASSWORD: ${{ inputs.docker-token }}
+45 -31
View File
@@ -25,10 +25,6 @@ on:
required: false
default: false
type: boolean
push:
branches:
- preview
- canary
env:
TARGET_BRANCH: ${{ github.ref_name }}
@@ -40,7 +36,7 @@ env:
jobs:
branch_build_setup:
name: Build Setup
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
@@ -164,17 +160,20 @@ jobs:
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
build-context: .
@@ -187,17 +186,20 @@ jobs:
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
build-context: .
@@ -210,17 +212,20 @@ jobs:
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
build-context: .
@@ -233,17 +238,20 @@ jobs:
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
build-context: .
@@ -256,17 +264,20 @@ jobs:
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
build-context: ./apiserver
@@ -279,17 +290,20 @@ jobs:
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./nginx
@@ -302,7 +316,7 @@ jobs:
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
- name: Checkout
@@ -327,7 +341,7 @@ jobs:
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs:
[
branch_build_setup,
@@ -71,7 +71,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=admin
@@ -84,7 +84,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=space
@@ -97,7 +97,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=web
@@ -109,7 +109,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn build --filter=admin
@@ -121,7 +121,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn build --filter=space
@@ -133,6 +133,6 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: yarn install
- run: yarn build --filter=web
+4 -5
View File
@@ -6,7 +6,6 @@
</a>
</p>
<h1 align="center"><b>Plane</b></h1>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -58,7 +57,7 @@ Prefer full control over your data and infrastructure? Install and run Plane on
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) |
`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
`Instance admins` can manage and customize settings using [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
## 🌟 Features
@@ -118,9 +117,9 @@ Setting up your local environment is simple and straightforward. Follow these st
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
## Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)<br/>
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)<br/>
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en)
## 📸 Screenshots
+3
View File
@@ -2,4 +2,7 @@ module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+3 -5
View File
@@ -1,9 +1,7 @@
FROM node:20-alpine as base
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -15,7 +13,7 @@ RUN turbo prune --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
FROM base AS installer
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -54,7 +52,7 @@ RUN yarn turbo run build --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=installer /app/admin/next.config.js .
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
+2 -2
View File
@@ -1,9 +1,9 @@
import React, { FC, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { InstanceService } from "@plane/services";
// ui
import { Button, Input } from "@plane/ui";
// services
import { InstanceService } from "@/services/instance.service";
type Props = {
isOpen: boolean;
+1 -1
View File
@@ -123,7 +123,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
in line with{" "}
<a
href="https://developers.plane.so/self-hosting/telemetry"
href="https://docs.plane.so/self-hosting/telemetry"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
+3 -3
View File
@@ -7,15 +7,15 @@ import { DefaultLayout } from "@/layouts/default-layout";
export const metadata: Metadata = {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
openGraph: {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
url: "https://plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
+7 -5
View File
@@ -2,16 +2,18 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
// constants
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { InstanceWorkspaceService } from "@plane/services";
// types
import { IWorkspace } from "@plane/types";
// components
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
// hooks
import { useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
const instanceWorkspaceService = new InstanceWorkspaceService();
const workspaceService = new WorkspaceService();
export const WorkspaceCreateForm = () => {
// router
@@ -38,8 +40,8 @@ export const WorkspaceCreateForm = () => {
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
const handleCreateWorkspace = async (formData: IWorkspace) => {
await instanceWorkspaceService
.slugCheck(formData.slug)
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
@@ -7,11 +7,12 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import {AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// hooks
import { useTheme, useUser } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
@@ -6,11 +6,12 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner, PasswordStrengthMeter } from "@/components/common";
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
@@ -336,7 +337,7 @@ export const InstanceSetupForm: FC = (props) => {
</label>
<a
tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry"
href="https://docs.plane.so/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600"
+2 -1
View File
@@ -5,12 +5,13 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import { authErrorHandler } from "@/lib/auth-helpers";
// services
import { AuthService } from "@/services/auth.service";
// local components
import { AuthBanner } from "../authentication";
+53
View File
@@ -0,0 +1,53 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// store
// import { rootStore } from "@/lib/store-context";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
this.setupInterceptors();
}
private setupInterceptors() {
// this.axiosInstance.interceptors.response.use(
// (response) => response,
// (error) => {
// const store = rootStore;
// if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
// return Promise.reject(error);
// }
// );
}
get<ResponseType>(url: string, params = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.get(url, { params });
}
post<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.post(url, data, config);
}
put<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.put(url, data, config);
}
patch<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.patch(url, data, config);
}
delete<RequestType>(url: string, data?: RequestType, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request<T>(config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
return this.axiosInstance(config);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { API_BASE_URL } from "@plane/constants";
// services
import { APIService } from "@/services/api.service";
type TCsrfTokenResponse = {
csrf_token: string;
};
export class AuthService extends APIService {
constructor() {
super(API_BASE_URL);
}
async requestCSRFToken(): Promise<TCsrfTokenResponse> {
return this.get<TCsrfTokenResponse>("/auth/get-csrf-token/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
}
+72
View File
@@ -0,0 +1,72 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type {
IFormattedInstanceConfiguration,
IInstance,
IInstanceAdmin,
IInstanceConfiguration,
IInstanceInfo,
} from "@plane/types";
// helpers
import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getInstanceInfo(): Promise<IInstanceInfo> {
return this.get<IInstanceInfo>("/api/instances/")
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInstanceAdmins(): Promise<IInstanceAdmin[]> {
return this.get<IInstanceAdmin[]>("/api/instances/admins/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async updateInstanceInfo(data: Partial<IInstance>): Promise<IInstance> {
return this.patch<Partial<IInstance>, IInstance>("/api/instances/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInstanceConfigurations() {
return this.get<IInstanceConfiguration[]>("/api/instances/configurations/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async updateInstanceConfigurations(
data: Partial<IFormattedInstanceConfiguration>
): Promise<IInstanceConfiguration[]> {
return this.patch<Partial<IFormattedInstanceConfiguration>, IInstanceConfiguration[]>(
"/api/instances/configurations/",
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async sendTestEmail(receiverEmail: string): Promise<undefined> {
return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", {
receiver_email: receiverEmail,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+29
View File
@@ -0,0 +1,29 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type { IUser } from "@plane/types";
// services
import { APIService } from "@/services/api.service";
interface IUserSession extends IUser {
isAuthenticated: boolean;
}
export class UserService extends APIService {
constructor() {
super(API_BASE_URL);
}
async authCheck(): Promise<IUserSession> {
return this.get<any>("/api/instances/admins/me/")
.then((response) => ({ ...response?.data, isAuthenticated: true }))
.catch(() => ({ isAuthenticated: false }));
}
async currentUser(): Promise<IUser> {
return this.get<IUser>("/api/instances/admins/me/")
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+52
View File
@@ -0,0 +1,52 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
// services
import { APIService } from "@/services/api.service";
export class WorkspaceService extends APIService {
constructor() {
super(API_BASE_URL);
}
/**
* @description Fetches all workspaces
* @returns Promise<TWorkspacePaginationInfo>
*/
async getWorkspaces(nextPageCursor?: string): Promise<TWorkspacePaginationInfo> {
return this.get<TWorkspacePaginationInfo>("/api/instances/workspaces/", {
cursor: nextPageCursor,
})
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Checks if a slug is available
* @param slug - string
* @returns Promise<any>
*/
async workspaceSlugCheck(slug: string): Promise<any> {
const params = new URLSearchParams({ slug });
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Creates a new workspace
* @param data - IWorkspace
* @returns Promise<IWorkspace>
*/
async createWorkspace(data: IWorkspace): Promise<IWorkspace> {
return this.post<IWorkspace, IWorkspace>("/api/instances/workspaces/", data)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+7 -6
View File
@@ -2,7 +2,6 @@ import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {InstanceService} from "@plane/services";
import {
IInstance,
IInstanceAdmin,
@@ -11,6 +10,8 @@ import {
IInstanceInfo,
IInstanceConfig,
} from "@plane/types";
// services
import { InstanceService } from "@/services/instance.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -95,7 +96,7 @@ export class InstanceStore implements IInstanceStore {
try {
if (this.instance === undefined) this.isLoading = true;
this.error = undefined;
const instanceInfo = await this.instanceService.info();
const instanceInfo = await this.instanceService.getInstanceInfo();
// handling the new user popup toggle
if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
this.store.theme.toggleNewUserPopup();
@@ -124,7 +125,7 @@ export class InstanceStore implements IInstanceStore {
*/
updateInstanceInfo = async (data: Partial<IInstance>) => {
try {
const instanceResponse = await this.instanceService.update(data);
const instanceResponse = await this.instanceService.updateInstanceInfo(data);
if (instanceResponse) {
runInAction(() => {
if (this.instance) set(this.instance, "instance", instanceResponse);
@@ -143,7 +144,7 @@ export class InstanceStore implements IInstanceStore {
*/
fetchInstanceAdmins = async () => {
try {
const instanceAdmins = await this.instanceService.admins();
const instanceAdmins = await this.instanceService.getInstanceAdmins();
if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins));
return instanceAdmins;
} catch (error) {
@@ -158,7 +159,7 @@ export class InstanceStore implements IInstanceStore {
*/
fetchInstanceConfigurations = async () => {
try {
const instanceConfigurations = await this.instanceService.configurations();
const instanceConfigurations = await this.instanceService.getInstanceConfigurations();
if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations));
return instanceConfigurations;
} catch (error) {
@@ -173,7 +174,7 @@ export class InstanceStore implements IInstanceStore {
*/
updateInstanceConfigurations = async (data: Partial<IFormattedInstanceConfiguration>) => {
try {
const response = await this.instanceService.updateConfigurations(data);
const response = await this.instanceService.updateInstanceConfigurations(data);
runInAction(() => {
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
const item = response.find((item) => item.key === config.key);
+4 -2
View File
@@ -1,8 +1,10 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import { EUserStatus, TUserStatus } from "@plane/constants";
import { AuthService, UserService } from "@plane/services";
import { IUser } from "@plane/types";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -56,7 +58,7 @@ export class UserStore implements IUserStore {
fetchCurrentUser = async () => {
try {
if (this.currentUser === undefined) this.isLoading = true;
const currentUser = await this.userService.adminDetails();
const currentUser = await this.userService.currentUser();
if (currentUser) {
await this.store.instance.fetchInstanceAdmins();
runInAction(() => {
+7 -7
View File
@@ -1,8 +1,8 @@
import set from "lodash/set";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
// plane imports
import { InstanceWorkspaceService } from "@plane/services";
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// services
import { WorkspaceService } from "@/services/workspace.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -29,7 +29,7 @@ export class WorkspaceStore implements IWorkspaceStore {
workspaces: Record<string, IWorkspace> = {};
paginationInfo: TPaginationInfo | undefined = undefined;
// services
instanceWorkspaceService;
workspaceService;
constructor(private store: CoreRootStore) {
makeObservable(this, {
@@ -48,7 +48,7 @@ export class WorkspaceStore implements IWorkspaceStore {
// curd actions
createWorkspace: action,
});
this.instanceWorkspaceService = new InstanceWorkspaceService();
this.workspaceService = new WorkspaceService();
}
// computed
@@ -84,7 +84,7 @@ export class WorkspaceStore implements IWorkspaceStore {
} else {
this.loader = "init-loader";
}
const paginatedWorkspaceData = await this.instanceWorkspaceService.list();
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces();
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
@@ -109,7 +109,7 @@ export class WorkspaceStore implements IWorkspaceStore {
if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
try {
this.loader = "pagination";
const paginatedWorkspaceData = await this.instanceWorkspaceService.list(this.paginationInfo.next_cursor);
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor);
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
@@ -135,7 +135,7 @@ export class WorkspaceStore implements IWorkspaceStore {
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
try {
this.loader = "mutation";
const workspace = await this.instanceWorkspaceService.create(data);
const workspace = await this.workspaceService.createWorkspace(data);
runInAction(() => {
set(this.workspaces, [workspace.id], workspace);
});
+5 -5
View File
@@ -18,14 +18,13 @@
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@sentry/nextjs": "^8.54.0",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.7.9",
"axios": "^1.7.4",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.20",
@@ -35,18 +34,19 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"tailwind-config-custom": "*",
"typescript": "5.3.3"
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
presets: [sharedConfig],
@@ -15,4 +15,3 @@ from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .intake import IntakeIssueSerializer
from .estimate import EstimatePointSerializer
-2
View File
@@ -72,7 +72,6 @@ class BaseSerializer(serializers.ModelSerializer):
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
EstimatePointSerializer,
)
# Expansion mapper
@@ -89,7 +88,6 @@ class BaseSerializer(serializers.ModelSerializer):
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"parent": IssueLiteSerializer,
"estimate_point": EstimatePointSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
+2 -12
View File
@@ -6,7 +6,6 @@ from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
@@ -31,20 +30,11 @@ class CycleSerializer(BaseSerializer):
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
str(data.get("end_date", None).date()), project_id
)
return data
@@ -1,10 +0,0 @@
# Module imports
from plane.db.models import EstimatePoint
from .base import BaseSerializer
class EstimatePointSerializer(BaseSerializer):
class Meta:
model = EstimatePoint
fields = ["id", "value"]
read_only_fields = fields
+61 -79
View File
@@ -1,7 +1,6 @@
# Django imports
from django.utils import timezone
from lxml import html
from django.db import IntegrityError
# Third party imports
from rest_framework import serializers
@@ -139,56 +138,47 @@ class IssueSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
try:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
except IntegrityError:
pass
for assignee_id in assignees
],
batch_size=10,
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
except IntegrityError:
pass
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
return issue
@@ -204,45 +194,37 @@ class IssueSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
-5
View File
@@ -71,9 +71,4 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentEndpoint.as_view(),
name="issue-attachment",
),
]
+38 -149
View File
@@ -1,10 +1,9 @@
# Python imports
import json
import uuid
from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpResponseRedirect
from django.db import IntegrityError
from django.db.models import (
Case,
@@ -20,11 +19,11 @@ from django.db.models import (
Subquery,
)
from django.utils import timezone
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
@@ -51,10 +50,8 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
Workspace,
)
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
@@ -943,162 +940,37 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [ProjectEntityPermission]
model = FileAsset
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
name = request.data.get("name")
type = request.data.get("type", False)
size = request.data.get("size")
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# Check if the request is valid
if not name or not size:
return Response(
{"error": "Invalid request.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
size_limit = min(size, settings.FILE_SIZE_LIMIT)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
{"error": "Invalid file type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
serializer = IssueAttachmentSerializer(data=request.data)
if (
request.data.get("external_id")
and request.data.get("external_source")
and FileAsset.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).exists()
):
asset = FileAsset.objects.filter(
project_id=project_id,
issue_attachment = FileAsset.objects.filter(
workspace__slug=slug,
external_source=request.data.get("external_source"),
project_id=project_id,
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue with the same external id and external source already exists",
"id": str(asset.id),
"error": "Issue attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
},
status=status.HTTP_409_CONFLICT,
)
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
external_id=external_id,
external_source=external_source,
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"attachment": IssueAttachmentSerializer(asset).data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.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"),
)
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
# Get the asset
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
# Check if the asset is uploaded
if not asset.is_uploaded:
return Response(
{"error": "The asset is not uploaded.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
# Get all the attachments
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
# Serialize the attachments
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachment)
# Send this activity only if the attachment is not uploaded before
if not issue_attachment.is_uploaded:
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
@@ -1110,13 +982,30 @@ class IssueAttachmentEndpoint(BaseAPIView):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Update the attachment
issue_attachment.is_uploaded = True
issue_attachment.created_by = request.user
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
+11 -14
View File
@@ -28,7 +28,7 @@ from plane.db.models import (
Workspace,
UserFavorite,
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -288,6 +288,16 @@ class ProjectAPIEndpoint(BaseAPIView):
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
@@ -326,19 +336,6 @@ class ProjectAPIEndpoint(BaseAPIView):
entity_type="project", entity_identifier=pk, project_id=pk
).delete()
project.delete()
webhook_activity.delay(
event="project",
verb="deleted",
field=None,
old_value=None,
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=request.META.get("HTTP_ORIGIN"),
event_id=project.id,
old_identifier=None,
new_identifier=None,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -19,10 +19,6 @@ from .workspace import (
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
WorkspaceUserPropertiesSerializer,
WorkspaceUserLinkSerializer,
WorkspaceRecentVisitSerializer,
WorkspaceHomePreferenceSerializer,
StickySerializer,
)
from .project import (
ProjectSerializer,
@@ -72,8 +68,6 @@ from .issue import (
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from .module import (
+3 -16
View File
@@ -20,25 +20,12 @@ class CycleWriteSerializer(BaseSerializer):
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = (
self.initial_data.get("project_id", None)
or (self.instance and self.instance.project_id)
or self.context.get("project_id", None)
)
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
str(data.get("end_date", None).date()), project_id
)
return data
+3 -3
View File
@@ -1,6 +1,6 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
from plane.db.models import Dashboard, Widget
# Third party frameworks
from rest_framework import serializers
@@ -8,7 +8,7 @@ from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = DeprecatedDashboard
model = Dashboard
fields = "__all__"
@@ -17,5 +17,5 @@ class WidgetSerializer(BaseSerializer):
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = DeprecatedWidget
model = Widget
fields = ["id", "key", "is_visible", "widget_filters"]
@@ -53,6 +53,7 @@ def get_entity_model_and_serializer(entity_type):
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
+63 -176
View File
@@ -2,7 +2,6 @@
from django.utils import timezone
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Third Party imports
from rest_framework import serializers
@@ -34,8 +33,6 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueVersion,
IssueDescriptionVersion,
)
@@ -135,56 +132,47 @@ class IssueCreateSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee
if default_assignee_id is not None:
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
except IntegrityError:
pass
for user in assignees
],
batch_size=10,
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
except IntegrityError:
pass
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
return issue
@@ -200,45 +188,37 @@ class IssueCreateSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
@@ -301,26 +281,10 @@ class IssueRelationSerializer(BaseSerializer):
)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"assignee_ids",
]
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
read_only_fields = ["workspace", "project"]
@@ -332,26 +296,10 @@ class RelatedIssueSerializer(BaseSerializer):
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
priority = serializers.CharField(source="issue.priority", read_only=True)
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"assignee_ids",
]
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
read_only_fields = ["workspace", "project"]
@@ -719,64 +667,3 @@ class IssueSubscriberSerializer(BaseSerializer):
model = IssueSubscriber
fields = "__all__"
read_only_fields = ["workspace", "project", "issue"]
class IssueVersionDetailSerializer(BaseSerializer):
class Meta:
model = IssueVersion
fields = [
"id",
"workspace",
"project",
"issue",
"parent",
"state",
"estimate_point",
"name",
"priority",
"start_date",
"target_date",
"assignees",
"sequence_id",
"labels",
"sort_order",
"completed_at",
"archived_at",
"is_draft",
"external_source",
"external_id",
"type",
"cycle",
"modules",
"meta",
"name",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "issue"]
class IssueDescriptionVersionDetailSerializer(BaseSerializer):
class Meta:
model = IssueDescriptionVersion
fields = [
"id",
"workspace",
"project",
"issue",
"description_binary",
"description_html",
"description_stripped",
"description_json",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "issue"]
-4
View File
@@ -54,8 +54,6 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
# Get the workspace id from the project
@@ -64,8 +62,6 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
+21 -2
View File
@@ -90,7 +90,17 @@ class ProjectLiteSerializer(BaseSerializer):
class ProjectListSerializer(DynamicBaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
archived_issues = serializers.IntegerField(read_only=True)
archived_sub_issues = serializers.IntegerField(read_only=True)
draft_issues = serializers.IntegerField(read_only=True)
draft_sub_issues = serializers.IntegerField(read_only=True)
sub_issues = serializers.IntegerField(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
anchor = serializers.CharField(read_only=True)
@@ -103,9 +113,14 @@ class ProjectListSerializer(DynamicBaseSerializer):
if project_members is not None:
# Filter members by the project ID
return [
member.member_id
{
"id": member.id,
"member_id": member.member_id,
"member__display_name": member.member.display_name,
"member__avatar": member.member.avatar,
"member__avatar_url": member.member.avatar_url,
}
for member in project_members
if member.is_active and not member.member.is_bot
]
return []
@@ -119,6 +134,10 @@ class ProjectDetailSerializer(BaseSerializer):
default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
anchor = serializers.CharField(read_only=True)
+6 -204
View File
@@ -1,40 +1,25 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
UserRecentVisit,
Issue,
Page,
Project,
ProjectMember,
WorkspaceHomePreference,
Sticky,
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
class WorkSpaceSerializer(DynamicBaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)
def validate_slug(self, value):
# Check if the slug is restricted
@@ -59,7 +44,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = ["name", "slug", "id", "logo_url"]
fields = ["name", "slug", "id"]
read_only_fields = fields
@@ -90,11 +75,9 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
invite_link = serializers.SerializerMethodField()
def get_invite_link(self, obj):
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
workspace = WorkSpaceSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = WorkspaceMemberInvite
@@ -108,7 +91,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
"responded_at",
"created_at",
"updated_at",
"invite_link",
]
@@ -124,183 +106,3 @@ class WorkspaceUserPropertiesSerializer(BaseSerializer):
model = WorkspaceUserProperties
fields = "__all__"
read_only_fields = ["workspace", "user"]
class WorkspaceUserLinkSerializer(BaseSerializer):
class Meta:
model = WorkspaceUserLink
fields = "__all__"
read_only_fields = ["workspace", "owner"]
def to_internal_value(self, data):
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
return super().to_internal_value(data)
def validate_url(self, value):
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=validated_data.get("workspace_id"),
owner_id=validated_data.get("owner_id")
)
if workspace_user_link.exists():
raise serializers.ValidationError(
{"error": "URL already exists for this workspace and owner"}
)
return super().create(validated_data)
def update(self, instance, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=instance.workspace_id,
owner=instance.owner
)
if workspace_user_link.exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this workspace and owner"}
)
return super().update(instance, validated_data)
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
class Meta:
model = Issue
fields = [
"id",
"name",
"state",
"priority",
"assignees",
"type",
"sequence_id",
"project_id",
"project_identifier",
]
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
class Meta:
model = Project
fields = ["id", "name", "logo_props", "project_members", "identifier"]
def get_project_members(self, obj):
members = ProjectMember.objects.filter(
project_id=obj.id, member__is_bot=False, is_active=True
).values_list("member", flat=True)
return members
class PageRecentVisitSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
project_identifier = serializers.SerializerMethodField()
class Meta:
model = Page
fields = [
"id",
"name",
"logo_props",
"project_id",
"owned_by",
"project_identifier",
]
def get_project_id(self, obj):
return (
obj.project_id
if hasattr(obj, "project_id")
else obj.projects.values_list("id", flat=True).first()
)
def get_project_identifier(self, obj):
project = obj.projects.first()
return project.identifier if project else None
def get_entity_model_and_serializer(entity_type):
entity_map = {
"issue": (Issue, IssueRecentVisitSerializer),
"page": (Page, PageRecentVisitSerializer),
"project": (Project, ProjectRecentVisitSerializer),
}
return entity_map.get(entity_type, (None, None))
class WorkspaceRecentVisitSerializer(BaseSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserRecentVisit
fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"]
read_only_fields = ["workspace", "owner", "created_by", "updated_by"]
def get_entity_data(self, obj):
entity_name = obj.entity_name
entity_identifier = obj.entity_identifier
entity_model, entity_serializer = get_entity_model_and_serializer(entity_name)
if entity_model and entity_serializer:
try:
entity = entity_model.objects.get(pk=entity_identifier)
return entity_serializer(entity).data
except entity_model.DoesNotExist:
return None
return None
class WorkspaceHomePreferenceSerializer(BaseSerializer):
class Meta:
model = WorkspaceHomePreference
fields = ["key", "is_enabled", "sort_order"]
read_only_fields = ["workspace", "created_by", "updated_by"]
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
class WorkspaceUserPreferenceSerializer(BaseSerializer):
class Meta:
model = WorkspaceUserPreference
fields = ["key", "is_pinned", "sort_order"]
read_only_fields = ["workspace", "created_by", "updated_by"]
-6
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
)
@@ -44,9 +43,4 @@ urlpatterns = [
DefaultAnalyticsEndpoint.as_view(),
name="default-analytics",
),
path(
"workspaces/<str:slug>/project-stats/",
ProjectStatsEndpoint.as_view(),
name="project-analytics",
),
]
-34
View File
@@ -24,10 +24,6 @@ from plane.app.views import (
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
urlpatterns = [
@@ -260,34 +256,4 @@ urlpatterns = [
IssueBulkUpdateDateEndpoint.as_view(),
name="project-issue-dates",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
IssueMetaEndpoint.as_view(),
name="issue-meta",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
IssueDetailIdentifierEndpoint.as_view(),
name="issue-detail-identifier",
),
]
-6
View File
@@ -8,7 +8,6 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@@ -79,9 +78,4 @@ urlpatterns = [
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]
-5
View File
@@ -23,11 +23,6 @@ urlpatterns = [
ProjectViewSet.as_view({"get": "list", "post": "create"}),
name="project",
),
path(
"workspaces/<str:slug>/projects/details/",
ProjectViewSet.as_view({"get": "list_detail"}),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectViewSet.as_view(
-57
View File
@@ -27,11 +27,6 @@ from plane.app.views import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
WorkspaceDraftIssueViewSet,
QuickLinkViewSet,
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
WorkspaceUserPreferenceViewSet,
)
@@ -218,56 +213,4 @@ urlpatterns = [
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
name="workspace-drafts-issues",
),
# quick link
path(
"workspaces/<str:slug>/quick-links/",
QuickLinkViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-quick-links",
),
path(
"workspaces/<str:slug>/quick-links/<uuid:pk>/",
QuickLinkViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="workspace-quick-links",
),
# Widgets
path(
"workspaces/<str:slug>/home-preferences/",
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
"workspaces/<str:slug>/home-preferences/<str:key>/",
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
"workspaces/<str:slug>/recent-visits/",
UserRecentVisitViewSet.as_view({"get": "list"}),
name="workspace-recent-visits",
),
path(
"workspaces/<str:slug>/stickies/",
WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-sticky",
),
path(
"workspaces/<str:slug>/stickies/<uuid:pk>/",
WorkspaceStickyViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="workspace-sticky",
),
# User Preference
path(
"workspaces/<str:slug>/sidebar-preferences/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]
-12
View File
@@ -41,14 +41,10 @@ from .workspace.base import (
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.home import WorkspaceHomePreferenceViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
from .workspace.recent_visit import UserRecentVisitViewSet
from .workspace.user_preference import WorkspaceUserPreferenceViewSet
from .workspace.member import (
WorkSpaceMemberViewSet,
@@ -76,8 +72,6 @@ from .workspace.user import (
from .workspace.estimate import WorkspaceEstimatesEndpoint
from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .state.base import StateViewSet
from .view.base import (
@@ -116,8 +110,6 @@ from .issue.base import (
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueBulkUpdateDateEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
from .issue.activity import IssueActivityEndpoint
@@ -144,8 +136,6 @@ from .issue.sub_issue import SubIssuesEndpoint
from .issue.subscriber import IssueSubscriberViewSet
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -165,7 +155,6 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint
@@ -192,7 +181,6 @@ from .analytic.base import (
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
)
from .notification.base import (
+2 -82
View File
@@ -3,7 +3,7 @@ from django.db.models import Count, F, Sum, Q
from django.db.models.functions import ExtractMonth
from django.utils import timezone
from django.db.models.functions import Concat
from django.db.models import Case, When, Value, OuterRef, Func
from django.db.models import Case, When, Value
from django.db import models
# Third party imports
@@ -15,16 +15,7 @@ from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import AnalyticViewSerializer
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.db.models import (
AnalyticView,
Issue,
Workspace,
Project,
ProjectMember,
Cycle,
Module,
)
from plane.db.models import AnalyticView, Issue, Workspace
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
@@ -450,74 +441,3 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
},
status=status.HTTP_200_OK,
)
class ProjectStatsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
fields = request.GET.get("fields", "").split(",")
project_ids = request.GET.get("project_ids", "")
valid_fields = {
"total_issues",
"completed_issues",
"total_members",
"total_cycles",
"total_modules",
}
requested_fields = set(filter(None, fields)) & valid_fields
if not requested_fields:
requested_fields = valid_fields
projects = Project.objects.filter(workspace__slug=slug)
if project_ids:
projects = projects.filter(id__in=project_ids.split(","))
annotations = {}
if "total_issues" in requested_fields:
annotations["total_issues"] = (
Issue.issue_objects.filter(project_id=OuterRef("pk"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
if "completed_issues" in requested_fields:
annotations["completed_issues"] = (
Issue.issue_objects.filter(
project_id=OuterRef("pk"), state__group="completed"
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
if "total_cycles" in requested_fields:
annotations["total_cycles"] = (
Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
if "total_modules" in requested_fields:
annotations["total_modules"] = (
Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
if "total_members" in requested_fields:
annotations["total_members"] = (
ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False, is_active=True
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
projects = projects.annotate(**annotations).values("id", *requested_fields)
return Response(projects, status=status.HTTP_200_OK)
+3 -19
View File
@@ -5,7 +5,6 @@ import uuid
from django.conf import settings
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.db import IntegrityError
# Third party imports
from rest_framework import status
@@ -680,30 +679,15 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
[self.save_project_cover(asset, project_id) for asset in assets]
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
# For some cases, the bulk api is called after the issue is deleted creating
# an integrity error
try:
assets.update(issue_id=entity_id)
except IntegrityError:
pass
assets.update(issue_id=entity_id)
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
# For some cases, the bulk api is called after the comment is deleted
# creating an integrity error
try:
assets.update(comment_id=entity_id)
except IntegrityError:
pass
assets.update(comment_id=entity_id)
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
assets.update(page_id=entity_id)
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
# For some cases, the bulk api is called after the draft issue is deleted
# creating an integrity error
try:
assets.update(draft_issue_id=entity_id)
except IntegrityError:
pass
assets.update(draft_issue_id=entity_id)
return Response(status=status.HTTP_204_NO_CONTENT)
+15 -57
View File
@@ -47,7 +47,6 @@ from plane.db.models import (
User,
Project,
ProjectMember,
UserRecentVisit,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
@@ -55,7 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
from plane.utils.timezone_converter import (
convert_utc_to_project_timezone,
convert_to_utc,
user_timezone_converter,
)
class CycleViewSet(BaseViewSet):
@@ -133,18 +136,6 @@ class CycleViewSet(BaseViewSet):
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group__in=["cancelled"],
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
status=Case(
When(
@@ -152,7 +143,10 @@ class CycleViewSet(BaseViewSet):
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
@@ -227,7 +221,6 @@ class CycleViewSet(BaseViewSet):
"is_favorite",
"total_issues",
"completed_issues",
"cancelled_issues",
"assignee_ids",
"status",
"version",
@@ -259,7 +252,6 @@ class CycleViewSet(BaseViewSet):
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"assignee_ids",
"status",
@@ -267,9 +259,7 @@ class CycleViewSet(BaseViewSet):
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -281,9 +271,7 @@ class CycleViewSet(BaseViewSet):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -318,11 +306,6 @@ class CycleViewSet(BaseViewSet):
.first()
)
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="cycle",
@@ -375,9 +358,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -407,11 +388,6 @@ class CycleViewSet(BaseViewSet):
"created_by",
).first()
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="cycle",
@@ -481,9 +457,7 @@ class CycleViewSet(BaseViewSet):
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -544,13 +518,6 @@ class CycleViewSet(BaseViewSet):
entity_identifier=pk,
project_id=project_id,
).delete()
# Delete the cycle from recent visits
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_name="cycle",
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -566,17 +533,8 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
is_start_date_end_date_equal = (
True if str(start_date) == str(end_date) else False
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
+16 -22
View File
@@ -32,15 +32,15 @@ from plane.app.serializers import (
WidgetSerializer,
)
from plane.db.models import (
DeprecatedDashboard,
DeprecatedDashboardWidget,
Dashboard,
DashboardWidget,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueRelation,
Project,
DeprecatedWidget,
Widget,
WorkspaceMember,
CycleIssue,
)
@@ -53,10 +53,10 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
@@ -133,13 +133,10 @@ def dashboard_overview_stats(self, request, slug):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
)
.filter(
@@ -179,13 +176,10 @@ def dashboard_assigned_issues(self, request, slug):
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
@@ -693,7 +687,7 @@ class DashboardEndpoint(BaseAPIView):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = DeprecatedDashboard.objects.get_or_create(
dashboard, created = Dashboard.objects.get_or_create(
type_identifier=dashboard_type,
owned_by=request.user,
is_default=True,
@@ -713,24 +707,24 @@ class DashboardEndpoint(BaseAPIView):
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = DeprecatedWidget.objects.filter(
key=widget_key
).values_list("id", flat=True)
widget = Widget.objects.filter(key=widget_key).values_list(
"id", flat=True
)
if widget:
updated_dashboard_widgets.append(
DeprecatedDashboardWidget(
DashboardWidget(
widget_id=widget, dashboard_id=dashboard.id
)
)
DeprecatedDashboardWidget.objects.bulk_create(
DashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
DeprecatedWidget.objects.annotate(
Widget.objects.annotate(
is_visible=Exists(
DeprecatedDashboardWidget.objects.filter(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
@@ -739,7 +733,7 @@ class DashboardEndpoint(BaseAPIView):
)
.annotate(
dashboard_filters=Subquery(
DeprecatedDashboardWidget.objects.filter(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
@@ -798,7 +792,7 @@ class DashboardEndpoint(BaseAPIView):
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
dashboard_widget = DashboardWidget.objects.filter(
widget_id=widget_id, dashboard_id=dashboard_id
).first()
dashboard_widget.is_visible = request.data.get(
+68 -152
View File
@@ -1,169 +1,71 @@
# Python import
import os
from typing import List, Dict, Tuple
# Third party import
import litellm
# Python imports
import requests
import os
from rest_framework import status
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
from rest_framework import status
# Module import
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import (ProjectLiteSerializer,
WorkspaceLiteSerializer)
from plane.db.models import Project, Workspace
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
# Django imports
# Module imports
from ..base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace, Project
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.license.utils.instance_value import get_configuration_value
class LLMProvider:
"""Base class for LLM provider configurations"""
name: str = ""
models: List[str] = []
default_model: str = ""
@classmethod
def get_config(cls) -> Dict[str, str | List[str]]:
return {
"name": cls.name,
"models": cls.models,
"default_model": cls.default_model,
}
class OpenAIProvider(LLMProvider):
name = "OpenAI"
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
default_model = "gpt-4o-mini"
class AnthropicProvider(LLMProvider):
name = "Anthropic"
models = [
"claude-3-5-sonnet-20240620",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-2.1",
"claude-2",
"claude-instant-1.2",
"claude-instant-1"
]
default_model = "claude-3-sonnet-20240229"
class GeminiProvider(LLMProvider):
name = "Gemini"
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
default_model = "gemini-pro"
SUPPORTED_PROVIDERS = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
"gemini": GeminiProvider,
}
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
"""
Helper to get LLM configuration values, returns:
- api_key, model, provider
"""
api_key, provider_key, model = get_configuration_value([
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", None),
},
{
"key": "LLM_PROVIDER",
"default": os.environ.get("LLM_PROVIDER", "openai"),
},
{
"key": "LLM_MODEL",
"default": os.environ.get("LLM_MODEL", None),
},
])
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
if not provider:
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
return None, None, None
if not api_key:
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
return None, None, None
# If no model specified, use provider's default
if not model:
model = provider.default_model
# Validate model is supported by provider
if model not in provider.models:
log_exception(ValueError(
f"Model {model} not supported by {provider.name}. "
f"Supported models: {', '.join(provider.models)}"
))
return None, None, None
return api_key, model, provider_key
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
"""Helper to get LLM completion response"""
final_text = task + "\n" + prompt
try:
# For Gemini, prepend provider name to model
if provider.lower() == "gemini":
model = f"gemini/{model}"
response = litellm.completion(
model=model,
messages=[{"role": "user", "content": final_text}],
api_key=api_key,
)
text = response.choices[0].message.content.strip()
return text, None
except Exception as e:
log_exception(e)
error_type = e.__class__.__name__
if error_type == "AuthenticationError":
return None, f"Invalid API key for {provider}"
elif error_type == "RateLimitError":
return None, f"Rate limit exceeded for {provider}"
else:
return None, f"Error occurred while generating response from {provider}"
class GPTIntegrationEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
api_key, model, provider = get_llm_config()
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
if not api_key or not model or not provider:
# Get the configuration value
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "LLM provider API key and model are required"},
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
final_text = task + "\n" + prompt
client = OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text.replace("\n", "<br/>"),
"response_html": text_html,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
},
@@ -174,33 +76,47 @@ class GPTIntegrationEndpoint(BaseAPIView):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
api_key, model, provider = get_llm_config()
if not api_key or not model or not provider:
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
# Get the configuration value
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "LLM provider API key and model are required"},
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
final_text = task + "\n" + prompt
client = OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
)
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text.replace("\n", "<br/>"),
},
status=status.HTTP_200_OK,
{"response": text, "response_html": text_html}, status=status.HTTP_200_OK
)
@@ -120,12 +120,10 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
-183
View File
@@ -44,7 +44,6 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
UserRecentVisit,
)
from plane.utils.grouper import (
issue_group_values,
@@ -672,13 +671,6 @@ class IssueViewSet(BaseViewSet):
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue.delete()
# delete the issue from recent visits
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_name="issue",
).delete(soft=False)
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
@@ -1096,178 +1088,3 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
return Response(
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
)
class IssueMetaEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
id=issue_id, project_id=project_id, workspace__slug=slug
)
return Response(
{
"sequence_id": issue.sequence_id,
"project_identifier": issue.project.identifier,
},
status=status.HTTP_200_OK,
)
class IssueDetailIdentifierEndpoint(BaseAPIView):
def get(self, request, slug, project_identifier, issue_identifier):
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
workspace__slug=slug,
)
# Check if the user is a member of the project
if not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
is_active=True,
).exists():
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the issue
issue = (
Issue.issue_objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(sequence_id=issue_identifier)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("issue", "actor"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
)
)
).first()
# Check if the issue exists
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=str(issue.id),
user_id=str(request.user.id),
project_id=str(project.id),
)
# Serialize the issue
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
+17 -26
View File
@@ -5,7 +5,6 @@ import json
from django.utils import timezone
from django.db.models import Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -165,32 +164,24 @@ class CommentReactionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, comment_id):
try:
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
actor_id=request.user.id,
comment_id=comment_id,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Reaction already exists for the user"},
status=status.HTTP_400_BAD_REQUEST,
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def destroy(self, request, slug, project_id, comment_id, reaction_code):
+2 -18
View File
@@ -35,9 +35,7 @@ class LabelViewSet(BaseViewSet):
.order_by("sort_order")
)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
@@ -55,20 +53,6 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, *args, **kwargs):
# Check if the label name is unique within the project
if (
"name" in request.data
and Label.objects.filter(
project_id=kwargs["project_id"], name=request.data["name"]
)
.exclude(pk=kwargs["pk"])
.exists()
):
return Response(
{"error": "Label with the same name already exists in the project"},
status=status.HTTP_400_BAD_REQUEST,
)
# call the parent method to perform the update
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@@ -88,7 +72,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
Label(
name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"),
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
+17 -9
View File
@@ -268,19 +268,27 @@ class IssueRelationViewSet(BaseViewSet):
)
def remove_relation(self, request, slug, project_id, issue_id):
relation_type = request.data.get("relation_type", None)
related_issue = request.data.get("related_issue", None)
issue_relations = IssueRelation.objects.filter(
workspace__slug=slug,
).filter(
Q(issue_id=related_issue, related_issue_id=issue_id)
| Q(issue_id=issue_id, related_issue_id=related_issue)
)
issue_relations = issue_relations.first()
if relation_type in ["blocking", "start_after", "finish_after"]:
issue_relation = IssueRelation.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=related_issue,
related_issue_id=issue_id,
)
else:
issue_relation = IssueRelation.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
related_issue_id=related_issue,
)
current_instance = json.dumps(
IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder
IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder
)
issue_relations.delete()
issue_relation.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
-118
View File
@@ -1,118 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import IssueVersion, IssueDescriptionVersion
from ..base import BaseAPIView
from plane.app.serializers import (
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
from plane.utils.global_paginator import paginate
from plane.utils.timezone_converter import user_timezone_converter
class IssueVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_version = IssueVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
serializer = IssueVersionDetailSerializer(issue_version)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_versions_queryset = IssueVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
paginated_data = paginate(
base_queryset=issue_versions_queryset,
queryset=issue_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
class IssueDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
serializer = IssueDescriptionVersionDetailSerializer(
issue_description_version
)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
-8
View File
@@ -54,7 +54,6 @@ from plane.db.models import (
ModuleLink,
ModuleUserProperties,
Project,
UserRecentVisit,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.timezone_converter import user_timezone_converter
@@ -809,13 +808,6 @@ class ModuleViewSet(BaseViewSet):
entity_identifier=pk,
project_id=project_id,
).delete()
# delete the module from recent visits
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_name="module",
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -69
View File
@@ -33,14 +33,13 @@ from plane.db.models import (
ProjectMember,
ProjectPage,
Project,
UserRecentVisit,
)
from plane.utils.error_codes import ERROR_CODES
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
@@ -122,8 +121,6 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
)
@@ -388,13 +385,6 @@ class PageViewSet(BaseViewSet):
entity_identifier=pk,
entity_type="page",
).delete()
# Delete the page from recent visit
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_name="page",
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -563,61 +553,3 @@ class PagesDescriptionViewSet(BaseViewSet):
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
# get all the project ids where page is present
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
"project_id", flat=True
)
page.pk = None
page.name = f"{page.name} (Copy)"
page.description_binary = None
page.owned_by = request.user
page.created_by = request.user
page.updated_by = request.user
page.save()
for project_id in project_ids:
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
{"description_html": page.description_html}, None, page.id
)
# Copy the s3 objects uploaded in the page
copy_s3_objects.delay(
entity_name="PAGE",
entity_identifier=page.id,
project_id=project_id,
slug=slug,
user_id=request.user.id,
)
page = (
Page.objects.filter(pk=page.id)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.first()
)
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
+99 -83
View File
@@ -6,7 +6,7 @@ import json
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
@@ -25,9 +25,12 @@ from plane.app.serializers import (
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.db.models import (
UserFavorite,
Cycle,
Intake,
DeployBoard,
IssueUserProperty,
Issue,
Module,
Project,
ProjectIdentifier,
ProjectMember,
@@ -36,7 +39,7 @@ from plane.db.models import (
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
@@ -70,6 +73,36 @@ class ProjectViewSet(BaseViewSet):
)
)
)
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
)
)
)
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False, is_active=True
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
@@ -100,7 +133,7 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list_detail(self, request, slug):
def list(self, request, slug):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
projects = self.get_queryset().order_by("sort_order", "name")
if WorkspaceMember.objects.filter(
@@ -137,73 +170,6 @@ class ProjectViewSet(BaseViewSet):
).data
return Response(projects, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
is_active=True,
).values("role")
)
.annotate(inbox_view=F("intake_view"))
.annotate(sort_order=Subquery(sort_order))
.distinct()
).values(
"id",
"name",
"identifier",
"sort_order",
"logo_props",
"member_role",
"archived_at",
"workspace",
"cycle_view",
"issue_views_view",
"module_view",
"page_view",
"inbox_view",
"project_lead",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=5
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=15
).exists():
projects = projects.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
return Response(projects, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -216,6 +182,58 @@ class ProjectViewSet(BaseViewSet):
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"), parent__isnull=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"), archived_at__isnull=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"), is_draft=True
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).first()
if project is None:
@@ -398,6 +416,16 @@ class ProjectViewSet(BaseViewSet):
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
@@ -444,19 +472,7 @@ class ProjectViewSet(BaseViewSet):
):
project = Project.objects.get(pk=pk)
project.delete()
webhook_activity.delay(
event="project",
verb="deleted",
field=None,
old_value=None,
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=request.META.get("HTTP_ORIGIN"),
event_id=project.id,
old_identifier=None,
new_identifier=None,
)
# Delete the project members
DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete()
+27 -34
View File
@@ -277,14 +277,28 @@ class SearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
base_filters = Q(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
role__gt=10,
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
# Add condition to include `issue_created_by` in the query
filters = Q(member_id=issue_created_by) | base_filters
else:
filters = base_filters
# Query to fetch users
users = (
ProjectMember.objects.filter(filters)
.annotate(
member__avatar_url=Case(
When(
@@ -304,35 +318,14 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)[:count]
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
+11 -10
View File
@@ -1,3 +1,5 @@
# Python imports
# Django imports
from django.db.models import Q
@@ -7,7 +9,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import Issue, ProjectMember, IssueRelation
from plane.db.models import Issue, ProjectMember
from plane.utils.issue_search import search_issues
@@ -45,18 +47,17 @@ class IssueSearchEndpoint(BaseAPIView):
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first()
related_issue_ids = IssueRelation.objects.filter(
Q(related_issue=issue) | Q(issue=issue)
).values_list(
"issue_id", "related_issue_id"
).distinct()
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
if issue:
issues = issues.filter(
~Q(pk=issue_id),
~Q(pk__in=related_issue_ids),
~(
Q(issue_related__issue=issue)
& Q(issue_related__deleted_at__isnull=True)
),
~(
Q(issue_relation__related_issue=issue)
& Q(issue_relation__deleted_at__isnull=True)
),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first()
+5 -33
View File
@@ -1,9 +1,6 @@
# Python imports
from itertools import groupby
# Django imports
from django.db.utils import IntegrityError
# Third party imports
from rest_framework.response import Response
from rest_framework import status
@@ -40,36 +37,11 @@ class StateViewSet(BaseViewSet):
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = StateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The state name is already taken"},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
try:
state = State.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The state name is already taken"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = StateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
-8
View File
@@ -24,7 +24,6 @@ from plane.db.models import (
ProjectMember,
Project,
CycleIssue,
UserRecentVisit,
)
from plane.utils.grouper import (
issue_group_values,
@@ -496,13 +495,6 @@ class IssueViewViewSet(BaseViewSet):
entity_identifier=pk,
entity_type="view",
).delete()
# Delete the page from recent visit
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_name="view",
).delete(soft=False)
else:
return Response(
{"error": "Only admin or owner can delete the view"},
+1 -1
View File
@@ -120,7 +120,7 @@ class WebhookLogsEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, webhook_id):
webhook_logs = WebhookLog.objects.filter(
workspace__slug=slug, webhook=webhook_id
workspace__slug=slug, webhook_id=webhook_id
)
serializer = WebhookLogSerializer(webhook_logs, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
+17 -16
View File
@@ -7,11 +7,9 @@ from datetime import date
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.db.models.fields import DateField
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
# Django imports
from django.http import HttpResponse
from django.utils import timezone
@@ -64,6 +62,12 @@ class WorkSpaceViewSet(BaseViewSet):
.values("count")
)
issue_count = (
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
return (
self.filter_queryset(super().get_queryset().select_related("owner"))
.order_by("name")
@@ -72,6 +76,8 @@ class WorkSpaceViewSet(BaseViewSet):
workspace_member__is_active=True,
)
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.select_related("owner")
)
def create(self, request):
@@ -117,14 +123,7 @@ class WorkSpaceViewSet(BaseViewSet):
role=20,
company_role=request.data.get("company_role", ""),
)
# Get total members and role
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
data = serializer.data
data["total_members"] = total_members
data["role"] = 20
return Response(data, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
status=status.HTTP_400_BAD_REQUEST,
@@ -167,9 +166,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.values("count")
)
role = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
.values("role")
issue_count = (
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspace = (
@@ -181,19 +182,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
),
)
)
.annotate(role=role, total_members=member_count)
.select_related("owner")
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.filter(
workspace_member__member=request.user, workspace_member__is_active=True
)
.distinct()
)
workspaces = WorkSpaceSerializer(
self.filter_queryset(workspace),
fields=fields if fields else None,
many=True,
).data
return Response(workspaces, status=status.HTTP_200_OK)
@@ -4,7 +4,6 @@ from rest_framework.response import Response
# Django modules
from django.db.models import Q
from django.db import IntegrityError
# Module imports
from plane.app.views.base import BaseAPIView
@@ -32,21 +31,16 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, favorite_id):
@@ -1,85 +0,0 @@
# Module imports
from ..base import BaseAPIView
from plane.db.models.workspace import WorkspaceHomePreference
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
# Third party imports
from rest_framework.response import Response
from rest_framework import status
class WorkspaceHomePreferenceViewSet(BaseAPIView):
model = WorkspaceHomePreference
def get_serializer_class(self):
return WorkspaceHomePreferenceSerializer
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
get_preference = WorkspaceHomePreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
create_preference_keys = []
keys = [
key
for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices
if key not in ["quick_tutorial", "new_at_plane"]
]
sort_order_counter = 1
for preference in keys:
if preference not in get_preference.values_list("key", flat=True):
create_preference_keys.append(preference)
sort_order = 1000 - sort_order_counter
preference = WorkspaceHomePreference.objects.bulk_create(
[
WorkspaceHomePreference(
key=key,
user=request.user,
workspace=workspace,
sort_order=sort_order,
)
for key in create_preference_keys
],
batch_size=10,
ignore_conflicts=True,
)
sort_order_counter += 1
preference = WorkspaceHomePreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
return Response(
preference.values("key", "is_enabled", "config", "sort_order"),
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceHomePreference.objects.filter(
key=key, workspace__slug=slug, user=request.user
).first()
if preference:
serializer = WorkspaceHomePreferenceSerializer(
preference, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST
)
@@ -251,7 +251,8 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace")
.select_related("workspace", "workspace__owner", "created_by")
.annotate(total_members=Count("workspace__workspace_member"))
)
@invalidate_cache(path="/api/workspaces/", user=False)
@@ -1,74 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import WorkspaceUserLink, Workspace
from plane.app.serializers import WorkspaceUserLinkSerializer
from ..base import BaseViewSet
from plane.app.permissions import allow_permission, ROLE
class QuickLinkViewSet(BaseViewSet):
model = WorkspaceUserLink
def get_serializer_class(self):
return WorkspaceUserLinkSerializer
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = WorkspaceUserLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def partial_update(self, request, slug, pk):
quick_link = WorkspaceUserLink.objects.filter(
pk=pk, workspace__slug=slug, owner=request.user
).first()
if quick_link:
serializer = WorkspaceUserLinkSerializer(
quick_link, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
try:
quick_link = WorkspaceUserLink.objects.get(
pk=pk, workspace__slug=slug, owner=request.user
)
serializer = WorkspaceUserLinkSerializer(quick_link)
return Response(serializer.data, status=status.HTTP_200_OK)
except WorkspaceUserLink.DoesNotExist:
return Response(
{"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def destroy(self, request, slug, pk):
quick_link = WorkspaceUserLink.objects.get(
pk=pk, workspace__slug=slug, owner=request.user
)
quick_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
quick_links = WorkspaceUserLink.objects.filter(
workspace__slug=slug, owner=request.user
)
serializer = WorkspaceUserLinkSerializer(quick_links, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1,35 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import UserRecentVisit
from plane.app.serializers import WorkspaceRecentVisitSerializer
# Modules imports
from ..base import BaseViewSet
from plane.app.permissions import allow_permission, ROLE
class UserRecentVisitViewSet(BaseViewSet):
model = UserRecentVisit
def get_serializer_class(self):
return WorkspaceRecentVisitSerializer
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
user_recent_visits = UserRecentVisit.objects.filter(
workspace__slug=slug, user=request.user
)
entity_name = request.query_params.get("entity_name")
if entity_name:
user_recent_visits = user_recent_visits.filter(entity_name=entity_name)
user_recent_visits = user_recent_visits.filter(
entity_name__in=["issue", "page", "project"]
)
serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1,59 +0,0 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from plane.app.views.base import BaseViewSet
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import Sticky, Workspace
from plane.app.serializers import StickySerializer
class WorkspaceStickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.select_related("workspace", "owner")
.distinct()
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-sort_order")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
+5 -20
View File
@@ -375,11 +375,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
state_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -394,11 +391,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
priority_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -432,11 +426,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assigned_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -447,11 +438,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -461,11 +449,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
@@ -1,85 +0,0 @@
# Module imports
from ..base import BaseAPIView
from plane.db.models.workspace import WorkspaceUserPreference
from plane.app.serializers.workspace import WorkspaceUserPreferenceSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace
# Third party imports
from rest_framework.response import Response
from rest_framework import status
class WorkspaceUserPreferenceViewSet(BaseAPIView):
model = WorkspaceUserPreference
def get_serializer_class(self):
return WorkspaceUserPreferenceSerializer
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
get_preference = WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
create_preference_keys = []
keys = [
key
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
]
for preference in keys:
if preference not in get_preference.values_list("key", flat=True):
create_preference_keys.append(preference)
preference = WorkspaceUserPreference.objects.bulk_create(
[
WorkspaceUserPreference(
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
)
for i, key in enumerate(create_preference_keys)
],
batch_size=10,
ignore_conflicts=True,
)
preferences = WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
).order_by("sort_order").values("key", "is_pinned", "sort_order")
user_preferences = {}
for preference in preferences:
user_preferences[(str(preference["key"]))] = {
"is_pinned": preference["is_pinned"],
"sort_order": preference["sort_order"],
}
return Response(
user_preferences,
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceUserPreference.objects.filter(
key=key, workspace__slug=slug, user=request.user
).first()
if preference:
serializer = WorkspaceUserPreferenceSerializer(
preference, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND
)
+1
View File
@@ -53,6 +53,7 @@ urlpatterns = [
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path("magic-sign-up/", MagicSignUpEndpoint.as_view(), name="magic-sign-up"),
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
-150
View File
@@ -1,150 +0,0 @@
# Python imports
import uuid
import base64
import requests
from bs4 import BeautifulSoup
# Django imports
from django.conf import settings
# Module imports
from plane.db.models import FileAsset, Page, Issue
from plane.utils.exception_logger import log_exception
from plane.settings.storage import S3Storage
from celery import shared_task
def get_entity_id_field(entity_type, entity_id):
entity_mapping = {
FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id},
FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id},
FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id},
FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id},
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id},
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id},
FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id},
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id},
FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {
"draft_issue_id": entity_id
},
}
return entity_mapping.get(entity_type, {})
def extract_asset_ids(html, tag):
try:
soup = BeautifulSoup(html, "html.parser")
return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")]
except Exception as e:
log_exception(e)
return []
def replace_asset_ids(html, tag, duplicated_assets):
try:
soup = BeautifulSoup(html, "html.parser")
for mention_tag in soup.find_all(tag):
for asset in duplicated_assets:
if mention_tag.get("src") == asset["old_asset_id"]:
mention_tag["src"] = asset["new_asset_id"]
return str(soup)
except Exception as e:
log_exception(e)
return html
def update_description(entity, duplicated_assets, tag):
updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets)
entity.description_html = updated_html
entity.save()
return updated_html
# Get the description binary and description from the live server
def sync_with_external_service(entity_name, description_html):
try:
data = {
"description_html": description_html,
"variant": "rich" if entity_name == "PAGE" else "document",
}
response = requests.post(
f"{settings.LIVE_BASE_URL}/convert-document/",
json=data,
headers=None,
)
if response.status_code == 200:
return response.json()
except requests.RequestException as e:
log_exception(e)
return {}
@shared_task
def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
"""
Step 1: Extract asset ids from the description_html of the entity
Step 2: Duplicate the assets
Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)
Step 4: Request the live server to generate the description_binary and description for the entity
"""
try:
model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name)
if not model_class:
raise ValueError(f"Unsupported entity_name: {entity_name}")
entity = model_class.objects.get(id=entity_identifier)
asset_ids = extract_asset_ids(entity.description_html, "image-component")
duplicated_assets = []
workspace = entity.workspace
storage = S3Storage()
original_assets = FileAsset.objects.filter(
workspace=workspace, project_id=project_id, id__in=asset_ids
)
for original_asset in original_assets:
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=user_id,
entity_type=original_asset.entity_type,
project_id=project_id,
storage_metadata=original_asset.storage_metadata,
**get_entity_id_field(original_asset.entity_type, entity_identifier),
)
storage.copy_object(original_asset.asset, destination_key)
duplicated_assets.append(
{
"new_asset_id": str(duplicated_asset.id),
"old_asset_id": str(original_asset.id),
}
)
if duplicated_assets:
FileAsset.objects.filter(
pk__in=[item["new_asset_id"] for item in duplicated_assets]
).update(is_uploaded=True)
updated_html = update_description(
entity, duplicated_assets, "image-component"
)
external_data = sync_with_external_service(entity_name, updated_html)
if external_data:
entity.description = external_data.get("description")
entity.description_binary = base64.b64decode(
external_data.get("description_binary")
)
entity.save()
return
except Exception as e:
log_exception(e)
return []
+1 -4
View File
@@ -82,10 +82,7 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name)(
manager="objects"
).all()
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
@@ -9,11 +9,10 @@ from celery import shared_task
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
# Module imports
from plane.utils.valid_uuid import is_valid_uuid
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.db.models import (
CommentReaction,
Cycle,
@@ -739,10 +738,8 @@ def delete_comment_activity(
issue_activities,
epoch,
):
requested_data = json.loads(requested_data) if requested_data is not None else None
issue_activities.append(
IssueActivity(
issue_comment_id=requested_data.get("comment_id", None),
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
@@ -791,15 +788,14 @@ def create_cycle_issue_activity(
issue_id=updated_record.get("issue_id"),
actor_id=actor_id,
verb="updated",
old_value=old_cycle.name if old_cycle else "",
new_value=new_cycle.name if new_cycle else "",
old_value=old_cycle.name,
new_value=new_cycle.name,
field="cycles",
project_id=project_id,
workspace_id=workspace_id,
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
to {new_cycle.name if new_cycle else ""}""",
old_identifier=old_cycle.id if old_cycle else None,
new_identifier=new_cycle.id if new_cycle else None,
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
epoch=epoch,
)
)
@@ -895,11 +891,11 @@ def create_module_issue_activity(
actor_id=actor_id,
verb="created",
old_value="",
new_value=module.name if module else "",
new_value=module.name,
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name if module else ''}",
comment=f"added module {module.name}",
new_identifier=requested_data.get("module_id"),
epoch=epoch,
)
@@ -1415,7 +1411,7 @@ def delete_issue_relation_activity(
),
project_id=project_id,
workspace_id=workspace_id,
comment=f"deleted {requested_data.get('relation_type')} relation",
comment=f'deleted {requested_data.get("relation_type")} relation',
old_identifier=requested_data.get("related_issue"),
epoch=epoch,
)
@@ -1569,10 +1565,6 @@ def issue_activity(
try:
issue_activities = []
# Validate UUIDs
if not is_valid_uuid(project_id):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
@@ -1,6 +1,5 @@
# Python imports
from django.utils import timezone
from django.db import DatabaseError
# Third party imports
from celery import shared_task
@@ -23,12 +22,8 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
).first()
if recent_visited:
# Check if the database is available
try:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
except DatabaseError:
pass
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
+5 -9
View File
@@ -136,7 +136,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
@@ -153,7 +153,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
@@ -304,7 +304,7 @@ def webhook_send_task(
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
@@ -319,7 +319,7 @@ def webhook_send_task(
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
@@ -387,11 +387,7 @@ def webhook_activity(
webhook=webhook.id,
slug=slug,
event=event,
event_data=(
{"id": event_id}
if verb == "deleted"
else get_model_data(event=event, event_id=event_id)
),
event_data=get_model_data(event=event, event_id=event_id),
action=verb,
current_site=current_site,
activity={
@@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, inviter):
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
user = User.objects.get(email=inviter)
user = User.objects.get(email=invitor)
workspace = Workspace.objects.get(pk=workspace_id)
workspace_member_invite = WorkspaceMemberInvite.objects.get(
@@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
)
# Relative link
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
# The complete url including the domain
abs_url = str(current_site) + relative_link
@@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
) = get_email_configuration()
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
context = {
"email": email,
@@ -78,9 +78,11 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully")
logging.getLogger("plane").info("Email sent succesfully")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
log_exception(e)
return
except Exception as e:
log_exception(e)
@@ -1,120 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-02 07:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0088_sticky_sort_order_workspaceuserlink"),
]
operations = [
migrations.CreateModel(
name="WorkspaceHomePreference",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("key", models.CharField(max_length=255)),
("is_enabled", models.BooleanField(default=True)),
("config", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_user_home_preferences",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_user_home_preferences",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace Home Preference",
"verbose_name_plural": "Workspace Home Preferences",
"db_table": "workspace_home_preferences",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="workspacehomepreference",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("workspace", "user", "key"),
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="workspacehomepreference",
unique_together={("workspace", "user", "key", "deleted_at")},
),
migrations.AlterField(
model_name="page",
name="name",
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name="sticky",
name="name",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='workspacehomepreference',
name='sort_order',
field=models.PositiveIntegerField(default=65535),
),
]
@@ -1,87 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-09 14:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0089_workspacehomepreference_and_more'),
]
operations = [
migrations.RenameModel(
old_name='Dashboard',
new_name='DeprecatedDashboard',
),
migrations.RenameModel(
old_name='DashboardWidget',
new_name='DeprecatedDashboardWidget',
),
migrations.RenameModel(
old_name='Widget',
new_name='DeprecatedWidget',
),
migrations.AlterModelOptions(
name='deprecateddashboard',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'},
),
migrations.AlterModelOptions(
name='deprecateddashboardwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'},
),
migrations.AlterModelOptions(
name='deprecatedwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'},
),
migrations.AlterField(
model_name='workspacehomepreference',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AlterModelTable(
name='deprecateddashboard',
table='deprecated_dashboards',
),
migrations.AlterModelTable(
name='deprecateddashboardwidget',
table='deprecated_dashboard_widgets',
),
migrations.AlterModelTable(
name='deprecatedwidget',
table='deprecated_widgets',
),
migrations.CreateModel(
name='WorkspaceUserPreference',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('key', models.CharField(max_length=255)),
('is_pinned', models.BooleanField(default=False)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to=settings.AUTH_USER_MODEL)),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')),
],
options={
'verbose_name': 'Workspace User Preference',
'verbose_name_plural': 'Workspace User Preferences',
'db_table': 'workspace_user_preferences',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='workspaceuserpreference',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'),
),
migrations.AlterUniqueTogether(
name='workspaceuserpreference',
unique_together={('workspace', 'user', 'key', 'deleted_at')},
),
]
@@ -1,33 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-30 16:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0090_rename_dashboard_deprecateddashboard_and_more'),
]
operations = [
migrations.AddField(
model_name='issuecomment',
name='edited_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='is_smooth_cursor_enabled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='userrecentvisit',
name='entity_name',
field=models.CharField(max_length=30),
),
migrations.AlterField(
model_name='webhooklog',
name='webhook',
field=models.UUIDField(),
)
]
+1 -3
View File
@@ -3,7 +3,7 @@ from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .draft import (
DraftIssue,
@@ -69,8 +69,6 @@ from .workspace import (
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
WorkspaceHomePreference,
WorkspaceUserPreference,
)
from .favorite import UserFavorite
+14 -14
View File
@@ -8,7 +8,7 @@ from ..mixins import TimeAuditModel
from .base import BaseModel
class DeprecatedDashboard(BaseModel):
class Dashboard(BaseModel):
DASHBOARD_CHOICES = (
("workspace", "Workspace"),
("project", "Project"),
@@ -36,13 +36,13 @@ class DeprecatedDashboard(BaseModel):
return f"{self.name}"
class Meta:
verbose_name = "DeprecatedDashboard"
verbose_name_plural = "DeprecatedDashboards"
db_table = "deprecated_dashboards"
verbose_name = "Dashboard"
verbose_name_plural = "Dashboards"
db_table = "dashboards"
ordering = ("-created_at",)
class DeprecatedWidget(TimeAuditModel):
class Widget(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
@@ -55,18 +55,18 @@ class DeprecatedWidget(TimeAuditModel):
return f"{self.key}"
class Meta:
verbose_name = "DeprecatedWidget"
verbose_name_plural = "DeprecatedWidgets"
db_table = "deprecated_widgets"
verbose_name = "Widget"
verbose_name_plural = "Widgets"
db_table = "widgets"
ordering = ("-created_at",)
class DeprecatedDashboardWidget(BaseModel):
class DashboardWidget(BaseModel):
widget = models.ForeignKey(
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
Widget, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
dashboard = models.ForeignKey(
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
is_visible = models.BooleanField(default=True)
sort_order = models.FloatField(default=65535)
@@ -86,7 +86,7 @@ class DeprecatedDashboardWidget(BaseModel):
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Deprecated Dashboard Widget"
verbose_name_plural = "Deprecated Dashboard Widgets"
db_table = "deprecated_dashboard_widgets"
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"
ordering = ("-created_at",)
-1
View File
@@ -467,7 +467,6 @@ class IssueComment(ProjectBaseModel):
)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
edited_at = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
self.comment_stripped = (
+1 -1
View File
@@ -20,7 +20,7 @@ class Page(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="pages"
)
name = models.TextField(blank=True)
name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
+1 -1
View File
@@ -17,7 +17,7 @@ class EntityNameEnum(models.TextChoices):
class UserRecentVisit(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=30)
entity_name = models.CharField(max_length=30, choices=EntityNameEnum.choices)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
+1 -10
View File
@@ -5,12 +5,9 @@ from django.db import models
# Module imports
from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel):
name = models.TextField(null=True, blank=True)
name = models.TextField()
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
@@ -36,12 +33,6 @@ class Sticky(BaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self._state.adding:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
-2
View File
@@ -186,8 +186,6 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
is_smooth_cursor_enabled = models.BooleanField(default=False)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
+2 -2
View File
@@ -66,7 +66,7 @@ class WebhookLog(BaseModel):
"db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs"
)
# Associated webhook
webhook = models.UUIDField()
webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs")
# Basic request details
event_type = models.CharField(max_length=255, blank=True, null=True)
@@ -89,4 +89,4 @@ class WebhookLog(BaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.event_type} {str(self.webhook)}"
return f"{self.event_type} {str(self.webhook.url)}"
+1 -85
View File
@@ -1,5 +1,4 @@
# Python imports
from django.db.models.functions import Ln
import pytz
# Django imports
@@ -342,87 +341,4 @@ class WorkspaceUserLink(WorkspaceBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.id} {self.url}"
class WorkspaceHomePreference(BaseModel):
"""Preference for the home page of a workspace for a user"""
class HomeWidgetKeys(models.TextChoices):
QUICK_LINKS = "quick_links", "Quick Links"
RECENTS = "recents", "Recents"
MY_STICKIES = "my_stickies", "My Stickies"
NEW_AT_PLANE = "new_at_plane", "New at Plane"
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_user_home_preferences",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workspace_user_home_preferences",
)
key = models.CharField(max_length=255)
is_enabled = models.BooleanField(default=True)
config = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user", "key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
)
]
verbose_name = "Workspace Home Preference"
verbose_name_plural = "Workspace Home Preferences"
db_table = "workspace_home_preferences"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.name} {self.user.email} {self.key}"
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
VIEWS = "views", "Views"
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
ANALYTICS = "analytics", "Analytics"
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
key = models.CharField(max_length=255)
is_pinned = models.BooleanField(default=False)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user", "key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null",
)
]
verbose_name = "Workspace User Preference"
verbose_name_plural = "Workspace User Preferences"
db_table = "workspace_user_preferences"
ordering = ("-created_at",)
return f"{self.workspace.id} {self.url}"
+9 -9
View File
@@ -290,12 +290,11 @@ class InstanceAdminSignInEndpoint(View):
# Fetch the user
user = User.objects.filter(email=email).first()
# Error out if the user is not present
if not user:
# is_active
if not user.is_active:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
@@ -303,11 +302,12 @@ class InstanceAdminSignInEndpoint(View):
)
return HttpResponseRedirect(url)
# is_active
if not user.is_active:
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
@@ -132,33 +132,20 @@ class Command(BaseCommand):
"is_encrypted": False,
},
{
"key": "LLM_API_KEY",
"value": os.environ.get("LLM_API_KEY"),
"category": "AI",
"key": "OPENAI_API_KEY",
"value": os.environ.get("OPENAI_API_KEY"),
"category": "OPENAI",
"is_encrypted": True,
},
{
"key": "LLM_PROVIDER",
"value": os.environ.get("LLM_PROVIDER", "openai"),
"category": "AI",
"is_encrypted": False,
},
{
"key": "LLM_MODEL",
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
"category": "AI",
"is_encrypted": False,
},
# Deprecated, use LLM_MODEL
{
"key": "GPT_ENGINE",
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
-14
View File
@@ -336,8 +336,6 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
@@ -363,18 +361,6 @@ ATTACHMENT_MIME_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.graphics",
# Microsoft Visio
"application/vnd.visio",
# Netpbm format
"image/x-portable-graymap",
"image/x-portable-bitmap",
"image/x-portable-pixmap",
# Open Office Bae
"application/vnd.oasis.opendocument.database",
# Audio
"audio/mpeg",
"audio/wav",
-14
View File
@@ -151,17 +151,3 @@ class S3Storage(S3Boto3Storage):
"ETag": response.get("ETag"),
"Metadata": response.get("Metadata", {}),
}
def copy_object(self, object_name, new_object_name):
"""Copy an S3 object to a new location"""
try:
response = self.s3_client.copy_object(
Bucket=self.aws_storage_bucket_name,
CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name},
Key=new_object_name,
)
except ClientError as e:
log_exception(e)
return None
return response
+10
View File
@@ -130,6 +130,15 @@ class IntakeIssuePublicViewSet(BaseViewSet):
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Intake Issues",
project_id=project_deploy_board.project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -139,6 +148,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state=state,
)
# Create an Issue Activity
+2 -2
View File
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.get(
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
)
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
+7 -32
View File
@@ -1,15 +1,8 @@
# Python imports
import pytz
from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
# Django imports
from django.utils import timezone
# Module imports
from plane.db.models import Project
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
@@ -35,9 +28,7 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone):
return queryset_values
def convert_to_utc(
date, project_id, is_start_date=False, is_start_date_end_date_equal=False
):
def convert_to_utc(date, project_id, is_start_date=False):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
@@ -69,29 +60,13 @@ def convert_to_utc(
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=0, seconds=1)
localized_datetime += timedelta(minutes=1)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
if utc_datetime.date() == current_datetime_in_utc.date():
return current_datetime_in_utc
return utc_datetime
else:
# If it's start an end date are equal, add 23 hours, 59 minutes, and 59 seconds
# to make it the end of the day
if is_start_date_end_date_equal:
localized_datetime += timedelta(hours=23, minutes=59, seconds=59)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Return the UTC datetime for storage
return utc_datetime
# Return the UTC datetime for storage
return utc_datetime
def convert_utc_to_project_timezone(utc_datetime, project_id):

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