release: v1.1.0 #7991
This commit is contained in:
commit
d5bad5aedc
2302 changed files with 97853 additions and 70644 deletions
7
.codespellrc
Normal file
7
.codespellrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
|
||||
check-hidden = true
|
||||
# ignore all CamelCase and camelCase
|
||||
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
|
||||
ignore-words-list = tread
|
||||
|
|
@ -61,3 +61,9 @@ temp/
|
|||
# Misc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore
|
||||
.react-router/
|
||||
build/
|
||||
node_modules/
|
||||
README.md
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -3,11 +3,9 @@ name: "CodeQL"
|
|||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["preview", "master"]
|
||||
branches: ["preview", "canary", "master"]
|
||||
pull_request:
|
||||
branches: ["develop", "preview", "master"]
|
||||
schedule:
|
||||
- cron: "53 19 * * 5"
|
||||
branches: ["preview", "canary", "master"]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
|
|
|||
25
.github/workflows/codespell.yml
vendored
Normal file
25
.github/workflows/codespell.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Codespell configuration is within .codespellrc
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [preview]
|
||||
pull_request:
|
||||
branches: [preview]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,9 +16,10 @@ node_modules
|
|||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
dist/
|
||||
out/
|
||||
build/
|
||||
.react-router/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
|
@ -99,3 +100,5 @@ dev-editor
|
|||
*.rdb.gz
|
||||
|
||||
storybook-static
|
||||
|
||||
CLAUDE.md
|
||||
|
|
|
|||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[tools]
|
||||
node = "22.18.0"
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
rules: {
|
||||
"no-duplicate-imports": "off",
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "separate-type-imports",
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
|
||||
// components
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Artificial Intelligence Settings - God Mode",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Monitor } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
|
@ -101,7 +107,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
|||
},
|
||||
];
|
||||
|
||||
const GITHUB_SERVICE_FIELD: TCopyField[] = [
|
||||
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Origin_URL",
|
||||
label: "Origin URL",
|
||||
|
|
@ -121,6 +127,9 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
|||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const GITHUB_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URI",
|
||||
label: "Callback URI",
|
||||
|
|
@ -208,12 +217,29 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for GitHub</div>
|
||||
{GITHUB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for GitHub</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 rounded-lg">
|
||||
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
|
||||
{GITHUB_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GitHub Authentication - God Mode",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import Image from "next/image";
|
|||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { FC, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GitLab Authentication - God Mode",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
// hooks
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Monitor } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
|
@ -90,7 +95,7 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
|
|||
},
|
||||
];
|
||||
|
||||
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
|
||||
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Origin_URL",
|
||||
label: "Origin URL",
|
||||
|
|
@ -110,6 +115,9 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
|
|||
</p>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URI",
|
||||
label: "Callback URI",
|
||||
|
|
@ -195,12 +203,29 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for Google</div>
|
||||
{GOOGLE_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="pt-2 text-xl font-medium">Plane-provided details for Google</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 rounded-lg">
|
||||
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
|
||||
{GOOGLE_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Google Authentication - God Mode",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
// hooks
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authentication Settings - Plane Web",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
|
||||
// ui
|
||||
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// local components
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface EmailLayoutProps {
|
||||
children: ReactNode;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { FC, useEffect, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { InstanceService } from "@plane/services";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { Input } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Telescope } from "lucide-react";
|
||||
// types
|
||||
import { IInstance, IInstanceAdmin } from "@plane/types";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IInstance, IInstanceAdmin } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
import { Input, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import type { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "General Settings - God Mode",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
|
||||
// components
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface ImageLayoutProps {
|
||||
children: ReactNode;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// components
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useState, useRef } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
|
@ -20,7 +21,7 @@ const helpOptions = [
|
|||
{
|
||||
name: "Documentation",
|
||||
href: "https://docs.plane.so/",
|
||||
Icon: FileText,
|
||||
Icon: PageIcon,
|
||||
},
|
||||
{
|
||||
name: "Join our Discord",
|
||||
|
|
@ -110,7 +111,7 @@ export const AdminSidebarHelpSection: FC = observer(() => {
|
|||
<Link href={href} key={name} target="_blank">
|
||||
<div className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
<Icon className="h-3.5 w-3.5 text-custom-text-200" size={14} />
|
||||
<Icon className="h-3.5 w-3.5 text-custom-text-200" width={14} height={14} />
|
||||
</div>
|
||||
<span className="text-xs">{name}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// components
|
||||
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workspace Management - God Mode",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import Link from "next/link";
|
|||
import useSWR from "swr";
|
||||
import { Loader as LoaderIcon } from "lucide-react";
|
||||
// types
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WorkspaceListItem } from "@/components/workspace/list-item";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane constants
|
||||
import { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAdminAuthErrorInfo | undefined;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// plane packages
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
|
||||
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { Banner } from "@/components/common/banner";
|
||||
// local components
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode, createContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toast } from "@plane/ui";
|
||||
import { Toast } from "@plane/propel/toast";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
|
||||
export const ToastWithTheme = () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
// plane imports
|
||||
import { ADMIN_BASE_PATH } from "@plane/constants";
|
||||
// styles
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Image from "next/image";
|
|||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
import type {
|
||||
TGetBaseAuthenticationModeProps,
|
||||
TInstanceAuthenticationMethodKeys,
|
||||
TInstanceAuthenticationModes,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React from "react";
|
|||
// icons
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const UpgradeButton: React.FC = () => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import Link from "next/link";
|
|||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import Link from "next/link";
|
|||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import Link from "next/link";
|
|||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type TBanner = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import React from "react";
|
||||
// ui
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// icons
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@plane/ui";
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/ui";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import type { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@plane/ui";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import PlaneTakeOffImage from "@/public/images/plane-takeoff.png";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Image from "next/image";
|
|||
import Link from "next/link";
|
||||
import { useTheme as nextUseTheme } from "next-themes";
|
||||
// ui
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import { IInstanceStore } from "@/store/instance.store";
|
||||
import type { IInstanceStore } from "@/store/instance.store";
|
||||
|
||||
export const useInstance = (): IInstanceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import { IThemeStore } from "@/store/theme.store";
|
||||
import type { IThemeStore } from "@/store/theme.store";
|
||||
|
||||
export const useTheme = (): IThemeStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import { IUserStore } from "@/store/user.store";
|
||||
import type { IUserStore } from "@/store/user.store";
|
||||
|
||||
export const useUser = (): IUserStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import { IWorkspaceStore } from "@/store/workspace.store";
|
||||
import type { IWorkspaceStore } from "@/store/workspace.store";
|
||||
|
||||
export const useWorkspace = (): IWorkspaceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import set from "lodash/set";
|
||||
import { set } from "lodash-es";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
import type { TInstanceStatus } from "@plane/constants";
|
||||
import { EInstanceStatus } from "@plane/constants";
|
||||
import { InstanceService } from "@plane/services";
|
||||
import {
|
||||
import type {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
IInstanceConfiguration,
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
IInstanceConfig,
|
||||
} from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { IInstanceStore, InstanceStore } from "./instance.store";
|
||||
import { IThemeStore, ThemeStore } from "./theme.store";
|
||||
import { IUserStore, UserStore } from "./user.store";
|
||||
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
|
||||
import type { IInstanceStore } from "./instance.store";
|
||||
import { InstanceStore } from "./instance.store";
|
||||
import type { IThemeStore } from "./theme.store";
|
||||
import { ThemeStore } from "./theme.store";
|
||||
import type { IUserStore } from "./user.store";
|
||||
import { UserStore } from "./user.store";
|
||||
import type { IWorkspaceStore } from "./workspace.store";
|
||||
import { WorkspaceStore } from "./workspace.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { action, observable, makeObservable } from "mobx";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TTheme = "dark" | "light";
|
||||
export interface IThemeStore {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { action, observable, runInAction, makeObservable } from "mobx";
|
||||
// plane internal packages
|
||||
import { EUserStatus, TUserStatus } from "@plane/constants";
|
||||
import type { TUserStatus } from "@plane/constants";
|
||||
import { EUserStatus } from "@plane/constants";
|
||||
import { AuthService, UserService } from "@plane/services";
|
||||
import { IUser } from "@plane/types";
|
||||
import type { IUser } from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import set from "lodash/set";
|
||||
import { set } from "lodash-es";
|
||||
import { action, observable, runInAction, makeObservable, computed } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
// observables
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"@plane/utils": "workspace:*",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
|
|
@ -45,11 +45,10 @@
|
|||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,27 @@
|
|||
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
|
||||
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
|
||||
--color-sidebar-shadow-4xl: var(--color-shadow-4xl);
|
||||
|
||||
/* toast theme */
|
||||
--color-toast-success-text: 178, 221, 181;
|
||||
--color-toast-error-text: 206, 44, 49;
|
||||
--color-toast-warning-text: 255, 186, 24;
|
||||
--color-toast-info-text: 141, 164, 239;
|
||||
--color-toast-loading-text: 255, 255, 255;
|
||||
--color-toast-secondary-text: 185, 187, 198;
|
||||
--color-toast-tertiary-text: 139, 141, 152;
|
||||
|
||||
--color-toast-success-background: 46, 46, 46;
|
||||
--color-toast-error-background: 46, 46, 46;
|
||||
--color-toast-warning-background: 46, 46, 46;
|
||||
--color-toast-info-background: 46, 46, 46;
|
||||
--color-toast-loading-background: 46, 46, 46;
|
||||
|
||||
--color-toast-success-border: 42, 126, 59;
|
||||
--color-toast-error-border: 100, 23, 35;
|
||||
--color-toast-warning-border: 79, 52, 34;
|
||||
--color-toast-info-border: 58, 91, 199;
|
||||
--color-toast-loading-border: 96, 100, 108;
|
||||
}
|
||||
|
||||
[data-theme="light"],
|
||||
|
|
@ -221,27 +242,6 @@
|
|||
--color-border-200: 38, 38, 38; /* subtle border- 2 */
|
||||
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||
|
||||
/* toast theme */
|
||||
--color-toast-success-text: 178, 221, 181;
|
||||
--color-toast-error-text: 206, 44, 49;
|
||||
--color-toast-warning-text: 255, 186, 24;
|
||||
--color-toast-info-text: 141, 164, 239;
|
||||
--color-toast-loading-text: 255, 255, 255;
|
||||
--color-toast-secondary-text: 185, 187, 198;
|
||||
--color-toast-tertiary-text: 139, 141, 152;
|
||||
|
||||
--color-toast-success-background: 46, 46, 46;
|
||||
--color-toast-error-background: 46, 46, 46;
|
||||
--color-toast-warning-background: 46, 46, 46;
|
||||
--color-toast-info-background: 46, 46, 46;
|
||||
--color-toast-loading-background: 46, 46, 46;
|
||||
|
||||
--color-toast-success-border: 42, 126, 59;
|
||||
--color-toast-error-border: 100, 23, 35;
|
||||
--color-toast-warning-border: 79, 52, 34;
|
||||
--color-toast-info-border: 58, 91, 199;
|
||||
--color-toast-loading-border: 96, 100, 108;
|
||||
}
|
||||
|
||||
[data-theme="dark-contrast"] {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ class ApiConfig(AppConfig):
|
|||
try:
|
||||
import plane.utils.openapi.auth # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ class AssetUpdateSerializer(serializers.Serializer):
|
|||
and upload confirmation for S3-based file storage workflows.
|
||||
"""
|
||||
|
||||
attributes = serializers.JSONField(
|
||||
required=False, help_text="Additional attributes to update for the asset"
|
||||
)
|
||||
attributes = serializers.JSONField(required=False, help_text="Additional attributes to update for the asset")
|
||||
|
||||
|
||||
class GenericAssetUploadSerializer(serializers.Serializer):
|
||||
|
|
@ -85,9 +83,7 @@ class GenericAssetUpdateSerializer(serializers.Serializer):
|
|||
upload completion marking and metadata finalization.
|
||||
"""
|
||||
|
||||
is_uploaded = serializers.BooleanField(
|
||||
default=True, help_text="Whether the asset has been successfully uploaded"
|
||||
)
|
||||
is_uploaded = serializers.BooleanField(default=True, help_text="Whether the asset has been successfully uploaded")
|
||||
|
||||
|
||||
class FileAssetSerializer(BaseSerializer):
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ class BaseSerializer(serializers.ModelSerializer):
|
|||
"""
|
||||
Adjust the serializer's fields based on the provided 'fields' list.
|
||||
|
||||
:param fields: List or dictionary specifying which fields to include in the serializer.
|
||||
:param fields: List or dictionary specifying which
|
||||
fields to include in the serializer.
|
||||
:return: The updated fields for the serializer.
|
||||
"""
|
||||
# Check each field_name in the provided fields.
|
||||
|
|
@ -102,13 +103,9 @@ class BaseSerializer(serializers.ModelSerializer):
|
|||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
if isinstance(response.get(expand), list):
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand), many=True
|
||||
)
|
||||
exp_serializer = expansion[expand](getattr(instance, expand), many=True)
|
||||
else:
|
||||
exp_serializer = expansion[expand](
|
||||
getattr(instance, expand)
|
||||
)
|
||||
exp_serializer = expansion[expand](getattr(instance, expand))
|
||||
response[expand] = exp_serializer.data
|
||||
else:
|
||||
# You might need to handle this case differently
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from rest_framework import serializers
|
|||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Cycle, CycleIssue
|
||||
from plane.db.models import Cycle, CycleIssue, User
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,13 @@ class CycleCreateSerializer(BaseSerializer):
|
|||
and UTC normalization for time-bound iteration planning and sprint management.
|
||||
"""
|
||||
|
||||
owned_by = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="User who owns the cycle. If not provided, defaults to the current user.",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
project = self.context.get("project")
|
||||
|
|
@ -55,14 +62,9 @@ class CycleCreateSerializer(BaseSerializer):
|
|||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
if 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") or (
|
||||
self.instance.project_id
|
||||
if self.instance and hasattr(self.instance, "project_id")
|
||||
else None
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
|
|
@ -77,6 +79,10 @@ class CycleCreateSerializer(BaseSerializer):
|
|||
date=str(data.get("end_date", None).date()),
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
if not data.get("owned_by"):
|
||||
data["owned_by"] = self.context["request"].user
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -166,9 +172,7 @@ class CycleIssueRequestSerializer(serializers.Serializer):
|
|||
cycle assignment and sprint planning workflows.
|
||||
"""
|
||||
|
||||
issues = serializers.ListField(
|
||||
child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
|
||||
)
|
||||
issues = serializers.ListField(child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle")
|
||||
|
||||
|
||||
class TransferCycleIssueRequestSerializer(serializers.Serializer):
|
||||
|
|
@ -179,6 +183,4 @@ class TransferCycleIssueRequestSerializer(serializers.Serializer):
|
|||
and relationship updates for sprint reallocation workflows.
|
||||
"""
|
||||
|
||||
new_cycle_id = serializers.UUIDField(
|
||||
help_text="ID of the target cycle to transfer issues to"
|
||||
)
|
||||
new_cycle_id = serializers.UUIDField(help_text="ID of the target cycle to transfer issues to")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Module improts
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueExpandSerializer
|
||||
from plane.db.models import IntakeIssue, Issue
|
||||
|
|
@ -98,9 +98,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
|
|||
and embedded issue updates for issue queue processing workflows.
|
||||
"""
|
||||
|
||||
issue = IssueForIntakeSerializer(
|
||||
required=False, help_text="Issue data to update in the intake issue"
|
||||
)
|
||||
issue = IssueForIntakeSerializer(required=False, help_text="Issue data to update in the intake issue")
|
||||
|
||||
class Meta:
|
||||
model = IntakeIssue
|
||||
|
|
@ -132,9 +130,5 @@ class IssueDataSerializer(serializers.Serializer):
|
|||
"""
|
||||
|
||||
name = serializers.CharField(max_length=255, help_text="Issue name")
|
||||
description_html = serializers.CharField(
|
||||
required=False, allow_null=True, help_text="Issue description HTML"
|
||||
)
|
||||
priority = serializers.ChoiceField(
|
||||
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
|
||||
)
|
||||
description_html = serializers.CharField(required=False, allow_null=True, help_text="Issue description HTML")
|
||||
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority")
|
||||
|
|
|
|||
|
|
@ -43,21 +43,18 @@ class IssueSerializer(BaseSerializer):
|
|||
Comprehensive work item serializer with full relationship management.
|
||||
|
||||
Handles complete work item lifecycle including assignees, labels, validation,
|
||||
and related model updates. Supports dynamic field expansion and HTML content processing.
|
||||
and related model updates. Supports dynamic field expansion and HTML content
|
||||
processing.
|
||||
"""
|
||||
|
||||
assignees = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.values_list("id", flat=True)
|
||||
),
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.values_list("id", flat=True)),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
labels = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
queryset=Label.objects.values_list("id", flat=True)
|
||||
),
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.values_list("id", flat=True)),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
|
@ -89,13 +86,9 @@ class IssueSerializer(BaseSerializer):
|
|||
|
||||
# Validate description content for security
|
||||
if data.get("description_html"):
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
data["description_html"]
|
||||
)
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
|
@ -103,9 +96,7 @@ class IssueSerializer(BaseSerializer):
|
|||
if data.get("description_binary"):
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
|
||||
# Validate assignees are from project
|
||||
if data.get("assignees", []):
|
||||
|
|
@ -125,13 +116,9 @@ class IssueSerializer(BaseSerializer):
|
|||
# Check state is from the project only else raise validation error
|
||||
if (
|
||||
data.get("state")
|
||||
and not State.objects.filter(
|
||||
project_id=self.context.get("project_id"), pk=data.get("state").id
|
||||
).exists()
|
||||
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state").id).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"State is not valid please pass a valid state_id"
|
||||
)
|
||||
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||
|
||||
# Check parent issue is from workspace as it can be cross workspace
|
||||
if (
|
||||
|
|
@ -142,9 +129,7 @@ class IssueSerializer(BaseSerializer):
|
|||
pk=data.get("parent").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Parent is not valid issue_id please pass a valid issue_id"
|
||||
)
|
||||
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
|
||||
|
||||
if (
|
||||
data.get("estimate_point")
|
||||
|
|
@ -154,9 +139,7 @@ class IssueSerializer(BaseSerializer):
|
|||
pk=data.get("estimate_point").id,
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Estimate point is not valid please pass a valid estimate_point_id"
|
||||
)
|
||||
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -172,14 +155,10 @@ class IssueSerializer(BaseSerializer):
|
|||
|
||||
if not issue_type:
|
||||
# Get default issue type
|
||||
issue_type = IssueType.objects.filter(
|
||||
project_issue_types__project_id=project_id, is_default=True
|
||||
).first()
|
||||
issue_type = IssueType.objects.filter(project_issue_types__project_id=project_id, is_default=True).first()
|
||||
issue_type = issue_type
|
||||
|
||||
issue = Issue.objects.create(
|
||||
**validated_data, project_id=project_id, type=issue_type
|
||||
)
|
||||
issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type)
|
||||
|
||||
# Issue Audit Users
|
||||
created_by_id = issue.created_by_id
|
||||
|
|
@ -311,35 +290,26 @@ class IssueSerializer(BaseSerializer):
|
|||
|
||||
data["assignees"] = UserLiteSerializer(
|
||||
User.objects.filter(
|
||||
pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
|
||||
"assignee_id", flat=True
|
||||
)
|
||||
pk__in=IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
|
||||
),
|
||||
many=True,
|
||||
).data
|
||||
else:
|
||||
data["assignees"] = [
|
||||
str(assignee)
|
||||
for assignee in IssueAssignee.objects.filter(
|
||||
issue=instance
|
||||
).values_list("assignee_id", flat=True)
|
||||
for assignee in IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
|
||||
]
|
||||
if "labels" in self.fields:
|
||||
if "labels" in self.expand:
|
||||
data["labels"] = LabelSerializer(
|
||||
Label.objects.filter(
|
||||
pk__in=IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
pk__in=IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
|
||||
),
|
||||
many=True,
|
||||
).data
|
||||
else:
|
||||
data["labels"] = [
|
||||
str(label)
|
||||
for label in IssueLabel.objects.filter(issue=instance).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
str(label) for label in IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
|
||||
]
|
||||
|
||||
return data
|
||||
|
|
@ -451,12 +421,8 @@ class IssueLinkCreateSerializer(BaseSerializer):
|
|||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
|
|
@ -477,15 +443,11 @@ class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
|
|||
|
||||
def update(self, instance, validated_data):
|
||||
if (
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=instance.issue_id
|
||||
)
|
||||
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
|
@ -676,17 +638,13 @@ class IssueExpandSerializer(BaseSerializer):
|
|||
expand = self.context.get("expand", [])
|
||||
if "labels" in expand:
|
||||
# Use prefetched data
|
||||
return LabelLiteSerializer(
|
||||
[il.label for il in obj.label_issue.all()], many=True
|
||||
).data
|
||||
return LabelLiteSerializer([il.label for il in obj.label_issue.all()], many=True).data
|
||||
return [il.label_id for il in obj.label_issue.all()]
|
||||
|
||||
def get_assignees(self, obj):
|
||||
expand = self.context.get("expand", [])
|
||||
if "assignees" in expand:
|
||||
return UserLiteSerializer(
|
||||
[ia.assignee for ia in obj.issue_assignee.all()], many=True
|
||||
).data
|
||||
return UserLiteSerializer([ia.assignee for ia in obj.issue_assignee.all()], many=True).data
|
||||
return [ia.assignee_id for ia in obj.issue_assignee.all()]
|
||||
|
||||
class Meta:
|
||||
|
|
@ -734,8 +692,6 @@ class IssueSearchSerializer(serializers.Serializer):
|
|||
id = serializers.CharField(required=True, help_text="Issue ID")
|
||||
name = serializers.CharField(required=True, help_text="Issue name")
|
||||
sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
|
||||
project__identifier = serializers.CharField(
|
||||
required=True, help_text="Project identifier"
|
||||
)
|
||||
project__identifier = serializers.CharField(required=True, help_text="Project identifier")
|
||||
project_id = serializers.CharField(required=True, help_text="Project ID")
|
||||
workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class ModuleCreateSerializer(BaseSerializer):
|
|||
"""
|
||||
Serializer for creating modules with member validation and date checking.
|
||||
|
||||
Handles module creation including member assignment validation, date range verification,
|
||||
and duplicate name prevention for feature-based project organization setup.
|
||||
Handles module creation including member assignment validation, date range
|
||||
verification, and duplicate name prevention for feature-based
|
||||
project organization setup.
|
||||
"""
|
||||
|
||||
members = serializers.ListField(
|
||||
|
|
@ -75,9 +76,15 @@ class ModuleCreateSerializer(BaseSerializer):
|
|||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if Module.objects.filter(name=module_name, project_id=project_id).exists():
|
||||
module = Module.objects.filter(name=module_name, project_id=project_id).first()
|
||||
if module:
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
{
|
||||
"id": str(module.id),
|
||||
"code": "MODULE_NAME_ALREADY_EXISTS",
|
||||
"error": "Module with this name already exists",
|
||||
"message": "Module with this name already exists",
|
||||
}
|
||||
)
|
||||
|
||||
module = Module.objects.create(**validated_data, project_id=project_id)
|
||||
|
|
@ -105,8 +112,9 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
|
|||
"""
|
||||
Serializer for updating modules with enhanced validation and member management.
|
||||
|
||||
Extends module creation with update-specific validations including member reassignment,
|
||||
name conflict checking, and relationship management for module modifications.
|
||||
Extends module creation with update-specific validations including
|
||||
member reassignment, name conflict checking,
|
||||
and relationship management for module modifications.
|
||||
"""
|
||||
|
||||
class Meta(ModuleCreateSerializer.Meta):
|
||||
|
|
@ -121,14 +129,8 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
|
|||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if (
|
||||
Module.objects.filter(name=module_name, project=instance.project)
|
||||
.exclude(id=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists():
|
||||
raise serializers.ValidationError({"error": "Module with this name already exists"})
|
||||
|
||||
if members is not None:
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
|
|
@ -155,8 +157,8 @@ class ModuleSerializer(BaseSerializer):
|
|||
"""
|
||||
Comprehensive module serializer with work item metrics and member management.
|
||||
|
||||
Provides complete module data including work item counts by status, member relationships,
|
||||
and progress tracking for feature-based project organization.
|
||||
Provides complete module data including work item counts by status, member
|
||||
relationships, and progress tracking for feature-based project organization.
|
||||
"""
|
||||
|
||||
members = serializers.ListField(
|
||||
|
|
@ -238,12 +240,8 @@ class ModuleLinkSerializer(BaseSerializer):
|
|||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"), module_id=validated_data.get("module_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
|
||||
return ModuleLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,7 @@ class ProjectCreateSerializer(BaseSerializer):
|
|||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("project_lead"),
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
|
||||
if data.get("default_assignee", None) is not None:
|
||||
# Check if the default assignee is a member of the workspace
|
||||
|
|
@ -76,9 +74,7 @@ class ProjectCreateSerializer(BaseSerializer):
|
|||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("default_assignee"),
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
raise serializers.ValidationError("Default assignee should be a user in the workspace")
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -87,14 +83,10 @@ class ProjectCreateSerializer(BaseSerializer):
|
|||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
|
||||
return project
|
||||
|
||||
|
||||
|
|
@ -119,25 +111,17 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
|
|||
"""Update a project"""
|
||||
if (
|
||||
validated_data.get("default_state", None) is not None
|
||||
and not State.objects.filter(
|
||||
project=instance, id=validated_data.get("default_state")
|
||||
).exists()
|
||||
and not State.objects.filter(project=instance, id=validated_data.get("default_state")).exists()
|
||||
):
|
||||
# Check if the default state is a state in the project
|
||||
raise serializers.ValidationError(
|
||||
"Default state should be a state in the project"
|
||||
)
|
||||
raise serializers.ValidationError("Default state should be a state in the project")
|
||||
|
||||
if (
|
||||
validated_data.get("estimate", None) is not None
|
||||
and not Estimate.objects.filter(
|
||||
project=instance, id=validated_data.get("estimate")
|
||||
).exists()
|
||||
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
|
||||
):
|
||||
# Check if the estimate is a estimate in the project
|
||||
raise serializers.ValidationError(
|
||||
"Estimate should be a estimate in the project"
|
||||
)
|
||||
raise serializers.ValidationError("Estimate should be a estimate in the project")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
|
@ -182,9 +166,7 @@ class ProjectSerializer(BaseSerializer):
|
|||
member_id=data.get("project_lead"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Project lead should be a user in the workspace"
|
||||
)
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
|
||||
# Check default assignee should be a member of the workspace
|
||||
if (
|
||||
|
|
@ -194,23 +176,17 @@ class ProjectSerializer(BaseSerializer):
|
|||
member_id=data.get("default_assignee"),
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Default assignee should be a user in the workspace"
|
||||
)
|
||||
raise serializers.ValidationError("Default assignee should be a user in the workspace")
|
||||
|
||||
# Validate description content for security
|
||||
if "description_html" in data and data["description_html"]:
|
||||
if isinstance(data["description_html"], dict):
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"]))
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -219,14 +195,10 @@ class ProjectSerializer(BaseSerializer):
|
|||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
if ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=self.context["workspace_id"]
|
||||
).exists():
|
||||
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
|
||||
raise serializers.ValidationError(detail="Project Identifier is taken")
|
||||
|
||||
project = Project.objects.create(
|
||||
**validated_data, workspace_id=self.context["workspace_id"]
|
||||
)
|
||||
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
|
||||
_ = ProjectIdentifier.objects.create(
|
||||
name=project.identifier,
|
||||
project=project,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ class StateSerializer(BaseSerializer):
|
|||
def validate(self, data):
|
||||
# If the default is being provided then make all other states default False
|
||||
if data.get("default", False):
|
||||
State.objects.filter(project_id=self.context.get("project_id")).update(
|
||||
default=False
|
||||
)
|
||||
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
from .asset import urlpatterns as asset_patterns
|
||||
from .cycle import urlpatterns as cycle_patterns
|
||||
from .intake import urlpatterns as intake_patterns
|
||||
from .label import urlpatterns as label_patterns
|
||||
from .member import urlpatterns as member_patterns
|
||||
from .module import urlpatterns as module_patterns
|
||||
from .project import urlpatterns as project_patterns
|
||||
from .state import urlpatterns as state_patterns
|
||||
from .issue import urlpatterns as issue_patterns
|
||||
from .cycle import urlpatterns as cycle_patterns
|
||||
from .module import urlpatterns as module_patterns
|
||||
from .intake import urlpatterns as intake_patterns
|
||||
from .member import urlpatterns as member_patterns
|
||||
from .asset import urlpatterns as asset_patterns
|
||||
from .user import urlpatterns as user_patterns
|
||||
from .work_item import urlpatterns as work_item_patterns
|
||||
|
||||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
*cycle_patterns,
|
||||
*intake_patterns,
|
||||
*label_patterns,
|
||||
*member_patterns,
|
||||
*module_patterns,
|
||||
*project_patterns,
|
||||
*state_patterns,
|
||||
*issue_patterns,
|
||||
*cycle_patterns,
|
||||
*module_patterns,
|
||||
*intake_patterns,
|
||||
*member_patterns,
|
||||
*user_patterns,
|
||||
*work_item_patterns,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ urlpatterns = [
|
|||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
|
||||
IntakeIssueDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
IntakeIssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="intake-issue",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
IssueListCreateAPIEndpoint,
|
||||
IssueDetailAPIEndpoint,
|
||||
LabelListCreateAPIEndpoint,
|
||||
LabelDetailAPIEndpoint,
|
||||
IssueLinkListCreateAPIEndpoint,
|
||||
IssueLinkDetailAPIEndpoint,
|
||||
IssueCommentListCreateAPIEndpoint,
|
||||
IssueCommentDetailAPIEndpoint,
|
||||
IssueActivityListAPIEndpoint,
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/search/",
|
||||
IssueSearchEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
|
||||
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
|
||||
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
|
||||
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
|
||||
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
|
||||
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
|
||||
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
|
||||
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
17
apps/api/plane/api/urls/label.py
Normal file
17
apps/api/plane/api/urls/label.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.urls import path
|
||||
|
||||
from plane.api.views import LabelListCreateAPIEndpoint, LabelDetailAPIEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
|
||||
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
|
||||
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="label",
|
||||
),
|
||||
]
|
||||
|
|
@ -19,9 +19,7 @@ urlpatterns = [
|
|||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(
|
||||
http_method_names=["post", "delete"]
|
||||
),
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
146
apps/api/plane/api/urls/work_item.py
Normal file
146
apps/api/plane/api/urls/work_item.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
IssueListCreateAPIEndpoint,
|
||||
IssueDetailAPIEndpoint,
|
||||
IssueLinkListCreateAPIEndpoint,
|
||||
IssueLinkDetailAPIEndpoint,
|
||||
IssueCommentListCreateAPIEndpoint,
|
||||
IssueCommentDetailAPIEndpoint,
|
||||
IssueActivityListAPIEndpoint,
|
||||
IssueActivityDetailAPIEndpoint,
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
# Deprecated url patterns
|
||||
old_url_patterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/search/",
|
||||
IssueSearchEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
|
||||
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
|
||||
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
|
||||
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
|
||||
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
|
||||
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
|
||||
# New url patterns with work-items as the prefix
|
||||
new_url_patterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/work-items/search/",
|
||||
IssueSearchEndpoint.as_view(http_method_names=["get"]),
|
||||
name="work-item-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="work-item-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/",
|
||||
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:pk>/",
|
||||
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/links/",
|
||||
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-link-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-link-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/comments/",
|
||||
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-comment-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-comment-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/activities/",
|
||||
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="work-item-activity-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/activities/<uuid:pk>/",
|
||||
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="work-item-activity-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/",
|
||||
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-attachment-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-attachment-detail",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = old_url_patterns + new_url_patterns
|
||||
|
|
@ -8,7 +8,7 @@ from django.conf import settings
|
|||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest
|
||||
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
|
|
@ -158,9 +158,7 @@ class UserAssetEndpoint(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
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -236,9 +234,7 @@ class UserAssetEndpoint(BaseAPIView):
|
|||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
|
@ -282,8 +278,9 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
def post(self, request):
|
||||
"""Generate presigned URL for user server asset upload.
|
||||
|
||||
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
|
||||
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
|
||||
Create a presigned URL for uploading user profile assets
|
||||
(avatar or cover image) using server credentials. This endpoint generates the
|
||||
necessary credentials for direct S3 upload with server-side authentication.
|
||||
"""
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
|
|
@ -334,9 +331,7 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
# 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
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -388,16 +383,15 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
def delete(self, request, asset_id):
|
||||
"""Delete user server asset.
|
||||
|
||||
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
|
||||
This performs a soft delete by marking the asset as deleted and updating the user's profile.
|
||||
Delete a user profile asset (avatar or cover image) using server credentials and
|
||||
remove its reference from the user profile. This performs a soft delete by marking the
|
||||
asset as deleted and updating the user's profile.
|
||||
"""
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
|
@ -429,9 +423,7 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id, workspace_id=workspace.id, is_deleted=False
|
||||
)
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace_id=workspace.id, is_deleted=False)
|
||||
|
||||
# Check if the asset exists and is uploaded
|
||||
if not asset.is_uploaded:
|
||||
|
|
@ -457,13 +449,9 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return Response(
|
||||
|
|
@ -565,14 +553,12 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
created_by=request.user,
|
||||
external_id=external_id,
|
||||
external_source=external_source,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues # noqa: E501
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request, is_server=True)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -611,9 +597,7 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
and trigger metadata extraction.
|
||||
"""
|
||||
try:
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id, workspace__slug=slug, is_deleted=False
|
||||
)
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False)
|
||||
|
||||
# Update is_uploaded status
|
||||
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
|
||||
|
|
@ -626,6 +610,4 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ class TimezoneMixin:
|
|||
timezone.deactivate()
|
||||
|
||||
|
||||
class BaseAPIView(
|
||||
TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator
|
||||
):
|
||||
class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator):
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
|
@ -56,9 +54,7 @@ class BaseAPIView(
|
|||
api_key = self.request.headers.get("X-Api-Key")
|
||||
|
||||
if api_key:
|
||||
service_token = APIToken.objects.filter(
|
||||
token=api_key, is_service=True
|
||||
).first()
|
||||
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
|
||||
|
||||
if service_token:
|
||||
throttle_classes.append(ServiceTokenRateThrottle())
|
||||
|
|
@ -113,9 +109,7 @@ class BaseAPIView(
|
|||
if settings.DEBUG:
|
||||
from django.db import connection
|
||||
|
||||
print(
|
||||
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
|
||||
)
|
||||
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
|
||||
return response
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
|
|
@ -151,14 +145,10 @@ class BaseAPIView(
|
|||
|
||||
@property
|
||||
def fields(self):
|
||||
fields = [
|
||||
field for field in self.request.GET.get("fields", "").split(",") if field
|
||||
]
|
||||
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
|
||||
return fields if fields else None
|
||||
|
||||
@property
|
||||
def expand(self):
|
||||
expand = [
|
||||
expand for expand in self.request.GET.get("expand", "").split(",") if expand
|
||||
]
|
||||
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
|
||||
return expand if expand else None
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
@cycle_docs(
|
||||
operation_id="list_cycles",
|
||||
summary="List cycles",
|
||||
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
|
||||
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", # noqa: E501
|
||||
parameters=[
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
|
|
@ -201,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
|
|
@ -260,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
|
||||
)
|
||||
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
|
|
@ -289,7 +285,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
@cycle_docs(
|
||||
operation_id="create_cycle",
|
||||
summary="Create cycle",
|
||||
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
|
||||
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", # noqa: E501
|
||||
request=OpenApiRequest(
|
||||
request=CycleCreateSerializer,
|
||||
examples=[CYCLE_CREATE_EXAMPLE],
|
||||
|
|
@ -308,14 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
Create a new development cycle with specified name, description, and date range.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleCreateSerializer(data=request.data)
|
||||
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
|
@ -340,7 +333,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
serializer.save(project_id=project_id)
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
|
|
@ -358,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
|||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -487,7 +478,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
@cycle_docs(
|
||||
operation_id="update_cycle",
|
||||
summary="Update cycle",
|
||||
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
|
||||
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", # noqa: E501
|
||||
request=OpenApiRequest(
|
||||
request=CycleUpdateSerializer,
|
||||
examples=[CYCLE_UPDATE_EXAMPLE],
|
||||
|
|
@ -508,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
|
|
@ -523,18 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
|
@ -542,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", cycle.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
|
|
@ -601,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
|
|
@ -625,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
|||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="cycle", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -765,15 +742,13 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
operation_id="archive_cycle",
|
||||
summary="Archive cycle",
|
||||
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
|
||||
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", # noqa: E501
|
||||
request={},
|
||||
responses={
|
||||
204: ARCHIVED_RESPONSE,
|
||||
|
|
@ -786,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
|||
Move a completed cycle to archived status for historical tracking.
|
||||
Only cycles that have ended can be archived.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
|
|
@ -819,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
|||
Restore an archived cycle to active status, making it available for regular use.
|
||||
The cycle will reappear in active cycle lists.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -884,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
# List
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
|
|
@ -923,15 +892,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
operation_id="add_cycle_work_items",
|
||||
summary="Add Work Items to Cycle",
|
||||
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
|
||||
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
|
||||
request=OpenApiRequest(
|
||||
request=CycleIssueRequestSerializer,
|
||||
examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
|
||||
|
|
@ -955,22 +922,24 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
|
||||
if not issues:
|
||||
return Response(
|
||||
{"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{
|
||||
"code": "CYCLE_COMPLETED",
|
||||
"message": "The Cycle has already been completed so no new issues can be added",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all CycleWorkItems already created
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
|
||||
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
|
|
@ -1021,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
"created_cycle_issues": serializers.serialize("json", created_records),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
|
|
@ -1099,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
|||
cycle_id=cycle_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = CycleIssueSerializer(
|
||||
cycle_issue, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@cycle_docs(
|
||||
|
|
@ -1154,7 +1119,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
@cycle_docs(
|
||||
operation_id="transfer_cycle_work_items",
|
||||
summary="Transfer cycle work items",
|
||||
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
|
||||
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", # noqa: E501
|
||||
request=OpenApiRequest(
|
||||
request=TransferCycleIssueRequestSerializer,
|
||||
examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
|
||||
|
|
@ -1207,14 +1172,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
|
|
@ -1324,9 +1285,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
|
|
@ -1353,9 +1312,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
|
|
@ -1376,9 +1333,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
|
|
@ -1445,19 +1400,13 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True, then="assignees__avatar"
|
||||
),
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
|
|
@ -1484,9 +1433,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_issues": item["total_issues"],
|
||||
|
|
@ -1508,11 +1455,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
|
|
@ -1558,9 +1501,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
|
|
@ -1588,9 +1529,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{
|
||||
"error": "The cycle where the issues are transferred is already completed"
|
||||
},
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -1614,9 +1553,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
|||
}
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
|
|
|
|||
|
|
@ -62,11 +62,9 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
project_id=self.kwargs.get("project_id"),
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
|
||||
|
||||
if intake is None and not project.intake_view:
|
||||
if intake is None or not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
|
|
@ -83,7 +81,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
@intake_docs(
|
||||
operation_id="get_intake_work_items_list",
|
||||
summary="List intake work items",
|
||||
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
|
||||
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", # noqa: E501
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
|
|
@ -119,7 +117,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
@intake_docs(
|
||||
operation_id="create_intake_work_item",
|
||||
summary="Create intake work item",
|
||||
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
|
||||
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", # noqa: E501
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
|
|
@ -144,22 +142,16 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
Automatically creates the work item with default triage state and tracks activity.
|
||||
"""
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -171,17 +163,13 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
"urgent",
|
||||
"none",
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
)
|
||||
|
|
@ -226,11 +214,9 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
project_id=self.kwargs.get("project_id"),
|
||||
).first()
|
||||
|
||||
project = Project.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
|
||||
|
||||
if intake is None and not project.intake_view:
|
||||
if intake is None or not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
|
|
@ -267,15 +253,13 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
Retrieve details of a specific intake work item.
|
||||
"""
|
||||
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
|
||||
intake_issue_data = IntakeIssueSerializer(
|
||||
intake_issue_queryset, fields=self.fields, expand=self.expand
|
||||
).data
|
||||
intake_issue_data = IntakeIssueSerializer(intake_issue_queryset, fields=self.fields, expand=self.expand).data
|
||||
return Response(intake_issue_data, status=status.HTTP_200_OK)
|
||||
|
||||
@intake_docs(
|
||||
operation_id="update_intake_work_item",
|
||||
summary="Update intake work item",
|
||||
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
|
||||
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", # noqa: E501
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
|
|
@ -300,18 +284,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
Modify an existing intake work item's properties or status for triage processing.
|
||||
Supports status changes like accept, reject, or mark as duplicate.
|
||||
"""
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -332,9 +312,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(request.user.id):
|
||||
return Response(
|
||||
{"error": "You cannot edit intake work items"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -349,10 +327,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
|
|
@ -373,9 +348,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
if project_member.role <= 5:
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get(
|
||||
"description_html", issue.description_html
|
||||
),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
|
|
@ -401,45 +374,31 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
return Response(
|
||||
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
if project_member.role > 15:
|
||||
serializer = IntakeIssueUpdateSerializer(
|
||||
intake_issue, data=request.data, partial=True
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
state = State.objects.filter(
|
||||
group="cancelled", workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.is_triage:
|
||||
# Move to default state
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, default=True
|
||||
).first()
|
||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
|
@ -461,14 +420,12 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
@intake_docs(
|
||||
operation_id="delete_intake_work_item",
|
||||
summary="Delete intake work item",
|
||||
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
|
||||
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", # noqa: E501
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
|
|
@ -484,18 +441,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
Permanently remove an intake work item from the triage queue.
|
||||
Also deletes the underlying work item if it hasn't been accepted yet.
|
||||
"""
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
|
||||
# Intake view
|
||||
if intake is None and not project.intake_view:
|
||||
return Response(
|
||||
{
|
||||
"error": "Intake is not enabled for this project enable it through the project's api"
|
||||
},
|
||||
{"error": "Intake is not enabled for this project enable it through the project's api"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -510,9 +463,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
# Check the issue status
|
||||
if intake_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).first()
|
||||
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
|
|
|||
|
|
@ -30,12 +30,10 @@ from rest_framework.response import Response
|
|||
# drf-spectacular imports
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
OpenApiExample,
|
||||
OpenApiRequest,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
|
|
@ -99,7 +97,6 @@ from plane.utils.openapi import (
|
|||
EXTERNAL_ID_PARAMETER,
|
||||
EXTERNAL_SOURCE_PARAMETER,
|
||||
ORDER_BY_PARAMETER,
|
||||
SEARCH_PARAMETER,
|
||||
SEARCH_PARAMETER_REQUIRED,
|
||||
LIMIT_PARAMETER,
|
||||
WORKSPACE_SEARCH_PARAMETER,
|
||||
|
|
@ -145,9 +142,8 @@ from plane.utils.openapi import (
|
|||
)
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
|
||||
def user_has_issue_permission(
|
||||
user_id, project_id, issue=None, allowed_roles=None, allow_creator=True
|
||||
):
|
||||
|
||||
def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True):
|
||||
if allow_creator and issue is not None and user_id == issue.created_by_id:
|
||||
return True
|
||||
|
||||
|
|
@ -272,7 +268,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
@work_item_docs(
|
||||
operation_id="list_work_items",
|
||||
summary="List work items",
|
||||
description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.",
|
||||
description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", # noqa: E501
|
||||
parameters=[
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
|
|
@ -325,9 +321,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
self.get_queryset()
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
|
|
@ -347,21 +341,14 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
)
|
||||
|
||||
total_issue_queryset = Issue.issue_objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
total_issue_queryset = Issue.issue_objects.filter(project_id=project_id, workspace__slug=slug)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order if order_by_param == "priority" else priority_order[::-1]
|
||||
)
|
||||
priority_order = priority_order if order_by_param == "priority" else priority_order[::-1]
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
|
@ -373,17 +360,10 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
state_order = state_order if order_by_param in ["state__name", "state__group"] else state_order[::-1]
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
*[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
|
|
@ -396,14 +376,8 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
max_values=Max(order_by_param[1::] if order_by_param.startswith("-") else order_by_param)
|
||||
).order_by("-max_values" if order_by_param.startswith("-") else "max_values")
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
|
|
@ -411,9 +385,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
request=request,
|
||||
queryset=(issue_queryset),
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@work_item_docs(
|
||||
|
|
@ -479,9 +451,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
|||
|
||||
serializer.save()
|
||||
# Refetch the issue
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]
|
||||
).first()
|
||||
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
|
||||
issue.created_at = request.data.get("created_at", timezone.now())
|
||||
issue.created_by_id = request.data.get("created_by", request.user.id)
|
||||
issue.save(update_fields=["created_at", "created_by"])
|
||||
|
|
@ -582,7 +552,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
@work_item_docs(
|
||||
operation_id="put_work_item",
|
||||
summary="Update or create work item",
|
||||
description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.",
|
||||
description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", # noqa: E501
|
||||
request=OpenApiRequest(
|
||||
request=IssueSerializer,
|
||||
examples=[ISSUE_UPSERT_EXAMPLE],
|
||||
|
|
@ -628,9 +598,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
|
||||
# Get the current instance of the issue in order to track
|
||||
# changes and dispatch the issue activity
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
|
||||
# Get the requested data, encode it as django object and pass it
|
||||
# to serializer to validation
|
||||
|
|
@ -693,16 +661,12 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
# the issue with the provided data, else return with the
|
||||
# default states given.
|
||||
issue.created_at = request.data.get("created_at", timezone.now())
|
||||
issue.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue.created_by_id = request.data.get("created_by", request.user.id)
|
||||
issue.save(update_fields=["created_at", "created_by"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
|
|
@ -720,7 +684,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
@work_item_docs(
|
||||
operation_id="update_work_item",
|
||||
summary="Partially update work item",
|
||||
description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.",
|
||||
description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", # noqa: E501
|
||||
parameters=[
|
||||
PROJECT_ID_PARAMETER,
|
||||
],
|
||||
|
|
@ -747,9 +711,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
serializer = IssueSerializer(
|
||||
issue,
|
||||
|
|
@ -764,9 +726,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", issue.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", issue.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
|
|
@ -794,7 +754,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
@work_item_docs(
|
||||
operation_id="delete_work_item",
|
||||
summary="Delete work item",
|
||||
description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.",
|
||||
description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", # noqa: E501
|
||||
parameters=[
|
||||
PROJECT_ID_PARAMETER,
|
||||
],
|
||||
|
|
@ -824,9 +784,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
|||
{"error": "Only admin or creator can delete the work item"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
|
|
@ -962,9 +920,7 @@ class LabelListCreateAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda labels: LabelSerializer(
|
||||
labels, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda labels: LabelSerializer(labels, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1036,9 +992,7 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
|
|||
and Label.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", label.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", label.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
)
|
||||
.exclude(id=pk)
|
||||
|
|
@ -1165,9 +1119,7 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
|
|||
serializer = IssueLinkCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.instance.id, serializer.instance.url
|
||||
)
|
||||
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url)
|
||||
link = IssueLink.objects.get(pk=serializer.instance.id)
|
||||
link.created_by_id = request.data.get("created_by", request.user.id)
|
||||
link.save(update_fields=["created_by"])
|
||||
|
|
@ -1236,9 +1188,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
if pk is None:
|
||||
issue_links = self.get_queryset()
|
||||
serializer = IssueLinkSerializer(
|
||||
issue_links, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = IssueLinkSerializer(issue_links, fields=self.fields, expand=self.expand)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
|
|
@ -1247,9 +1197,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
|||
).data,
|
||||
)
|
||||
issue_link = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueLinkSerializer(
|
||||
issue_link, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@issue_link_docs(
|
||||
|
|
@ -1279,19 +1227,13 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
|||
Modify the URL, title, or metadata of an existing issue link.
|
||||
Tracks all changes in issue activity logs.
|
||||
"""
|
||||
issue_link = IssueLink.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
|
||||
requested_data = json.dumps(request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder)
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
|
|
@ -1323,12 +1265,8 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
|||
Permanently remove an external link from a work item.
|
||||
Records deletion activity for audit purposes.
|
||||
"""
|
||||
issue_link = IssueLink.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
|
||||
current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder)
|
||||
issue_activity.delay(
|
||||
type="link.activity.deleted",
|
||||
requested_data=json.dumps({"link_id": str(pk)}),
|
||||
|
|
@ -1464,15 +1402,12 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
|
|||
|
||||
serializer = IssueCommentCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, issue_id=issue_id, actor=request.user
|
||||
)
|
||||
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
|
||||
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
|
||||
# Update the created_at and the created_by and save the comment
|
||||
issue_comment.created_at = request.data.get("created_at", timezone.now())
|
||||
issue_comment.created_by_id = request.data.get(
|
||||
"created_by", request.user.id
|
||||
)
|
||||
issue_comment.created_by_id = request.data.get("created_by", request.user.id)
|
||||
issue_comment.actor_id = request.data.get("created_by", request.user.id)
|
||||
issue_comment.save(update_fields=["created_at", "created_by"])
|
||||
|
||||
issue_activity.delay(
|
||||
|
|
@ -1558,9 +1493,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
|
|||
Retrieve details of a specific comment.
|
||||
"""
|
||||
issue_comment = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueCommentSerializer(
|
||||
issue_comment, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = IssueCommentSerializer(issue_comment, fields=self.fields, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@issue_comment_docs(
|
||||
|
|
@ -1591,13 +1524,9 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
|
|||
Modify the content of an existing comment on a work item.
|
||||
Validates external ID uniqueness if provided.
|
||||
"""
|
||||
issue_comment = IssueComment.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = json.dumps(
|
||||
IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
|
||||
|
||||
# Validation check if the issue already exists
|
||||
if (
|
||||
|
|
@ -1606,9 +1535,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
|
|||
and IssueComment.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", issue_comment.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", issue_comment.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
|
|
@ -1620,9 +1547,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer = IssueCommentCreateSerializer(
|
||||
issue_comment, data=request.data, partial=True
|
||||
)
|
||||
serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
|
|
@ -1668,12 +1593,8 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
|
|||
Permanently remove a comment from a work item.
|
||||
Records deletion activity for audit purposes.
|
||||
"""
|
||||
issue_comment = IssueComment.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
|
||||
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
|
||||
issue_comment.delete()
|
||||
issue_activity.delay(
|
||||
type="comment.activity.deleted",
|
||||
|
|
@ -1720,9 +1641,7 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
|
|||
Excludes comment, vote, reaction, and draft activities.
|
||||
"""
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
|
|
@ -1777,9 +1696,7 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView):
|
|||
Excludes comment, vote, reaction, and draft activities.
|
||||
"""
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
|
|
@ -1869,12 +1786,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
|||
name="Workspace not found",
|
||||
value={"error": "Workspace not found"},
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Project not found", value={"error": "Project not found"}
|
||||
),
|
||||
OpenApiExample(
|
||||
name="Issue not found", value={"error": "Issue not found"}
|
||||
),
|
||||
OpenApiExample(name="Project not found", value={"error": "Project not found"}),
|
||||
OpenApiExample(name="Issue not found", value={"error": "Issue not found"}),
|
||||
],
|
||||
),
|
||||
},
|
||||
|
|
@ -1885,9 +1798,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
|||
Generate presigned URL for uploading file attachments to a work item.
|
||||
Validates file type and size before creating the attachment record.
|
||||
"""
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
# if the user is creator or admin,member then allow the upload
|
||||
if not user_has_issue_permission(
|
||||
request.user.id,
|
||||
|
|
@ -1973,9 +1884,7 @@ class IssueAttachmentListCreateAPIEndpoint(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
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -2035,9 +1944,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
ATTACHMENT_ID_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
204: OpenApiResponse(
|
||||
description="Work item attachment deleted successfully"
|
||||
),
|
||||
204: OpenApiResponse(description="Work item attachment deleted successfully"),
|
||||
404: ATTACHMENT_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
|
|
@ -2047,9 +1954,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
Soft delete an attachment from a work item by marking it as deleted.
|
||||
Records deletion activity and triggers metadata cleanup.
|
||||
"""
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
# if the request user is creator or admin then delete the attachment
|
||||
if not user_has_issue_permission(
|
||||
request.user,
|
||||
|
|
@ -2063,9 +1968,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
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()
|
||||
|
|
@ -2139,9 +2042,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
|
|
@ -2179,9 +2080,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
204: OpenApiResponse(
|
||||
description="Work item attachment uploaded successfully"
|
||||
),
|
||||
204: OpenApiResponse(description="Work item attachment uploaded successfully"),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
404: ATTACHMENT_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
|
|
@ -2193,9 +2092,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
Triggers activity logging and metadata extraction.
|
||||
"""
|
||||
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
# if the user is creator or admin then allow the upload
|
||||
if not user_has_issue_permission(
|
||||
request.user,
|
||||
|
|
@ -2209,9 +2106,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -74,9 +74,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
).select_related("member")
|
||||
workspace_members = WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member")
|
||||
|
||||
# Get all the users with their roles
|
||||
users_with_roles = []
|
||||
|
|
@ -125,13 +123,11 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
# Get the workspace members that are present inside the workspace
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).values_list("member_id", flat=True)
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
|
||||
# Get all the users that are present inside the workspace
|
||||
users = UserLiteSerializer(
|
||||
User.objects.filter(id__in=project_members), many=True
|
||||
).data
|
||||
users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data
|
||||
|
||||
return Response(users, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
|
|
@ -41,8 +41,6 @@ from plane.utils.host import base_host
|
|||
from plane.utils.openapi import (
|
||||
module_docs,
|
||||
module_issue_docs,
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
MODULE_ID_PARAMETER,
|
||||
MODULE_PK_PARAMETER,
|
||||
ISSUE_ID_PARAMETER,
|
||||
|
|
@ -396,9 +394,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
|||
examples=[MODULE_UPDATE_EXAMPLE],
|
||||
),
|
||||
404: OpenApiResponse(description="Module not found"),
|
||||
409: OpenApiResponse(
|
||||
description="Module with same external ID already exists"
|
||||
),
|
||||
409: OpenApiResponse(description="Module with same external ID already exists"),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
|
|
@ -409,18 +405,14 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
|
||||
|
||||
current_instance = json.dumps(
|
||||
ModuleSerializer(module).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(ModuleSerializer(module).data, cls=DjangoJSONEncoder)
|
||||
|
||||
if module.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived module cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = ModuleSerializer(
|
||||
module, data=request.data, context={"project_id": project_id}, partial=True
|
||||
)
|
||||
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
|
@ -428,9 +420,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
|||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", module.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", module.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
|
|
@ -516,9 +506,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
|
||||
)
|
||||
module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True))
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
|
|
@ -539,9 +527,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
|||
# Delete the module issues
|
||||
ModuleIssue.objects.filter(module=pk, project_id=project_id).delete()
|
||||
# Delete the user favorite module
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="module", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
UserFavorite.objects.filter(entity_type="module", entity_identifier=pk, project_id=project_id).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -611,9 +597,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=module_id, issue_module__deleted_at__isnull=True
|
||||
)
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id, issue_module__deleted_at__isnull=True)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
|
|
@ -649,15 +633,13 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@module_issue_docs(
|
||||
operation_id="add_module_work_items",
|
||||
summary="Add Work Items to Module",
|
||||
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
|
||||
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
|
||||
parameters=[
|
||||
MODULE_ID_PARAMETER,
|
||||
],
|
||||
|
|
@ -683,16 +665,12 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
issues = request.data.get("issues", [])
|
||||
if not len(issues):
|
||||
return Response(
|
||||
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=module_id
|
||||
)
|
||||
return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=module_id)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issues
|
||||
).values_list("id", flat=True)
|
||||
issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issues).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
|
||||
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
|
||||
|
||||
|
|
@ -701,11 +679,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
record_to_create = []
|
||||
|
||||
for issue in issues:
|
||||
module_issue = [
|
||||
module_issue
|
||||
for module_issue in module_issues
|
||||
if str(module_issue.issue_id) in issues
|
||||
]
|
||||
module_issue = [module_issue for module_issue in module_issues if str(module_issue.issue_id) in issues]
|
||||
|
||||
if len(module_issue):
|
||||
if module_issue[0].module_id != module_id:
|
||||
|
|
@ -730,9 +704,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
)
|
||||
|
||||
ModuleIssue.objects.bulk_create(
|
||||
record_to_create, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
ModuleIssue.objects.bulk_create(record_to_create, batch_size=10, ignore_conflicts=True)
|
||||
|
||||
ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10)
|
||||
|
||||
|
|
@ -746,9 +718,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
"created_module_issues": serializers.serialize("json", record_to_create),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
|
|
@ -873,9 +843,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@module_issue_docs(
|
||||
|
|
@ -906,9 +874,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
|
|||
module_issue.delete()
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}
|
||||
),
|
||||
requested_data=json.dumps({"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
|
||||
|
||||
# Module imports
|
||||
|
|
@ -79,9 +79,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
| Q(network=2)
|
||||
)
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.select_related("project_lead")
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
|
|
@ -170,9 +168,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
|||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=slug, is_active=True
|
||||
).select_related("member"),
|
||||
queryset=ProjectMember.objects.filter(workspace__slug=slug, is_active=True).select_related(
|
||||
"member"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(request.GET.get("order_by", "sort_order"))
|
||||
|
|
@ -211,24 +209,18 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = ProjectCreateSerializer(
|
||||
data={**request.data}, context={"workspace_id": workspace.id}
|
||||
)
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id, member=request.user, role=20
|
||||
)
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(
|
||||
project_id=serializer.instance.id, user=request.user
|
||||
)
|
||||
_ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user)
|
||||
|
||||
if serializer.instance.project_lead is not None and str(
|
||||
serializer.instance.project_lead
|
||||
) != str(request.user.id):
|
||||
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
|
||||
request.user.id
|
||||
):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id,
|
||||
member_id=serializer.instance.project_lead,
|
||||
|
|
@ -314,9 +306,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
|
|
@ -344,9 +334,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
| Q(network=2)
|
||||
)
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.select_related("workspace", "workspace__owner", "default_assignee", "project_lead")
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
|
|
@ -451,9 +439,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
|||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=pk)
|
||||
current_instance = json.dumps(
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
|
||||
|
||||
intake_view = request.data.get("intake_view", project.intake_view)
|
||||
|
||||
|
|
@ -473,9 +459,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
|||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["intake_view"]:
|
||||
intake = Intake.objects.filter(
|
||||
project=project, is_default=True
|
||||
).first()
|
||||
intake = Intake.objects.filter(project=project, is_default=True).first()
|
||||
if not intake:
|
||||
Intake.objects.create(
|
||||
name=f"{project.name} Intake",
|
||||
|
|
@ -505,9 +489,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
|||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
|
|
@ -533,9 +515,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
|
|||
"""
|
||||
project = Project.objects.get(pk=pk, workspace__slug=slug)
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="project", entity_identifier=pk, project_id=pk
|
||||
).delete()
|
||||
UserFavorite.objects.filter(entity_type="project", entity_identifier=pk, project_id=pk).delete()
|
||||
project.delete()
|
||||
webhook_activity.delay(
|
||||
event="project",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from django.db import IntegrityError
|
|||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
|
||||
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import StateSerializer
|
||||
|
|
@ -80,9 +80,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
|
|||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
try:
|
||||
serializer = StateSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
serializer = StateSerializer(data=request.data, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
|
@ -153,9 +151,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
|
|||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda states: StateSerializer(
|
||||
states, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda states: StateSerializer(states, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -213,7 +209,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
|||
@state_docs(
|
||||
operation_id="delete_state",
|
||||
summary="Delete state",
|
||||
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
|
||||
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", # noqa: E501
|
||||
parameters=[
|
||||
STATE_ID_PARAMETER,
|
||||
],
|
||||
|
|
@ -228,9 +224,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
|||
Permanently remove a workflow state from a project.
|
||||
Default states and states with existing work items cannot be deleted.
|
||||
"""
|
||||
state = State.objects.get(
|
||||
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
state = State.objects.get(is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug)
|
||||
|
||||
if state.default:
|
||||
return Response(
|
||||
|
|
@ -277,9 +271,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
|||
Partially update an existing workflow state's properties like name, color, or group.
|
||||
Validates external ID uniqueness if provided.
|
||||
"""
|
||||
state = State.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=state_id
|
||||
)
|
||||
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
|
|
@ -288,9 +280,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
|
|||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", state.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", state.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ from .project import (
|
|||
ProjectLitePermission,
|
||||
)
|
||||
from .base import allow_permission, ROLE
|
||||
from .page import ProjectPagePermission
|
||||
|
|
|
|||
|
|
@ -18,16 +18,12 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
|||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for creator if required
|
||||
if creator and model:
|
||||
obj = model.objects.filter(
|
||||
id=kwargs["pk"], created_by=request.user
|
||||
).exists()
|
||||
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Convert allowed_roles to their values if they are enum members
|
||||
allowed_role_values = [
|
||||
role.value if isinstance(role, ROLE) else role for role in allowed_roles
|
||||
]
|
||||
allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles]
|
||||
|
||||
# Check role permissions
|
||||
if level == "WORKSPACE":
|
||||
|
|
@ -39,13 +35,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
|||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
if ProjectMember.objects.filter(
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
|
|
|
|||
121
apps/api/plane/app/permissions/page.py
Normal file
121
apps/api/plane/app/permissions/page.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from plane.db.models import ProjectMember, Page
|
||||
from plane.app.permissions import ROLE
|
||||
|
||||
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
# Permission Mappings for workspace members
|
||||
ADMIN = ROLE.ADMIN.value
|
||||
MEMBER = ROLE.MEMBER.value
|
||||
GUEST = ROLE.GUEST.value
|
||||
|
||||
|
||||
class ProjectPagePermission(BasePermission):
|
||||
"""
|
||||
Custom permission to control access to pages within a workspace
|
||||
based on user roles, page visibility (public/private), and feature flags.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check basic project-level permissions before checking object-level permissions.
|
||||
"""
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
user_id = request.user.id
|
||||
slug = view.kwargs.get("slug")
|
||||
page_id = view.kwargs.get("page_id")
|
||||
project_id = view.kwargs.get("project_id")
|
||||
|
||||
# Hook for extended validation
|
||||
extended_access, role = self._check_access_and_get_role(request, slug, project_id)
|
||||
if extended_access is False:
|
||||
return False
|
||||
|
||||
if page_id:
|
||||
page = Page.objects.get(id=page_id, workspace__slug=slug)
|
||||
|
||||
# Allow access if the user is the owner of the page
|
||||
if page.owned_by_id == user_id:
|
||||
return True
|
||||
|
||||
# Handle private page access
|
||||
if page.access == Page.PRIVATE_ACCESS:
|
||||
return self._has_private_page_action_access(request, slug, page, project_id)
|
||||
|
||||
# Handle public page access
|
||||
return self._has_public_page_action_access(request, role)
|
||||
|
||||
def _check_project_member_access(self, request, slug, project_id):
|
||||
"""
|
||||
Check if the user is a project member.
|
||||
"""
|
||||
return (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
project_id=project_id,
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _check_access_and_get_role(self, request, slug, project_id):
|
||||
"""
|
||||
Hook for extended access checking
|
||||
Returns: True (allow), False (deny), None (continue with normal flow)
|
||||
"""
|
||||
role = self._check_project_member_access(request, slug, project_id)
|
||||
if not role:
|
||||
return False, None
|
||||
return True, role
|
||||
|
||||
def _has_private_page_action_access(self, request, slug, page, project_id):
|
||||
"""
|
||||
Check access to private pages. Override for feature flag logic.
|
||||
"""
|
||||
# Base implementation: only owner can access private pages
|
||||
return False
|
||||
|
||||
def _check_project_action_access(self, request, role):
|
||||
method = request.method
|
||||
|
||||
# Only admins can create (POST) pages
|
||||
if method == "POST":
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
|
||||
if method in SAFE_METHODS:
|
||||
if role in [ADMIN, MEMBER, GUEST]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# PUT/PATCH: Admins and members can update
|
||||
if method in ["PUT", "PATCH"]:
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# DELETE: Only admins can delete
|
||||
if method == "DELETE":
|
||||
if role in [ADMIN]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Deny by default
|
||||
return False
|
||||
|
||||
def _has_public_page_action_access(self, request, role):
|
||||
"""
|
||||
Check if the user has permission to access a public page
|
||||
and can perform operations on the page.
|
||||
"""
|
||||
project_member_exists = self._check_project_action_access(request, role)
|
||||
if not project_member_exists:
|
||||
return False
|
||||
return True
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue