Compare commits

..
Author SHA1 Message Date
pablohashescobar 1053ff4684 feat: add tour_completed_features to Profile model and implement validation in ProfileSerializer
- Introduced a new JSONField `tour_completed_features` in the Profile model with a default value from `get_default_tour_completed_features`.
- Added validation logic in ProfileSerializer to clean and structure `tour_completed_features` input, ensuring only valid keys are retained.
- Updated ExporterHistory model to change `url` field from URLField to TextField for better flexibility.
- Created a new migration to apply these changes to the database.
2025-10-08 17:21:45 +05:30
1729 changed files with 10537 additions and 26893 deletions
-6
View File
@@ -61,9 +61,3 @@ 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
+1 -5
View File
@@ -16,10 +16,9 @@ node_modules
/out/
# Production
/build
dist/
out/
build/
.react-router/
# Misc
.DS_Store
@@ -102,6 +101,3 @@ dev-editor
storybook-static
CLAUDE.md
AGENTS.md
temp/
-14
View File
@@ -1,18 +1,4 @@
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,
},
],
},
};
+3 -4
View File
@@ -1,13 +1,12 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
// components
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Artificial Intelligence Settings - God Mode",
@@ -1,210 +0,0 @@
"use client";
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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types";
import { Button, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
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";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
export const InstanceGiteaConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GiteaConfigFormValues>({
defaultValues: {
GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com",
GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"],
GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITEA_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITEA_HOST",
type: "text",
label: "Gitea Host",
description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</>
),
placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST),
required: true,
},
{
key: "GITEA_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITEA_CLIENT_ID),
required: true,
},
{
key: "GITEA_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITEA_CLIENT_SECRET),
required: true,
},
];
const GITEA_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/gitea/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GiteaConfigFormValues) => {
const payload: Partial<GiteaConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Gitea authentication is configured. You should test it now.",
});
reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-xl font-medium">Gitea-provided details for Plane</div>
{GITEA_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</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 Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Gitea Authentication - God Mode",
};
export default function GiteaAuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,104 +0,0 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import giteaLogo from "@/public/logos/gitea-logo.svg";
//local components
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// config
const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGiteaEnabled = enableGiteaConfig === "1";
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<Image src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
value={isGiteaEnabled}
onChange={() => {
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<InstanceGiteaConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGiteaAuthenticationPage;
@@ -1,7 +1,6 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { FC, useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -10,16 +9,14 @@ import { Monitor } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
import { 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 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";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "GitHub Authentication - God Mode",
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -1,5 +1,4 @@
import type { FC } from "react";
import { useState } from "react";
import { FC, useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -7,15 +6,13 @@ import { useForm } from "react-hook-form";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
import { 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 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";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "GitLab Authentication - God Mode",
@@ -38,7 +38,7 @@ const InstanceGitlabAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { FC, useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
@@ -9,15 +8,13 @@ import { Monitor } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
import { 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 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";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
import { CopyField, TCopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Google Authentication - God Mode",
@@ -38,7 +38,7 @@ const InstanceGoogleAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Authentication Settings - Plane Web",
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -1,17 +1,15 @@
"use client";
import type { FC } from "react";
import React, { useMemo, useState } from "react";
import React, { FC, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
// components
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// local components
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
interface EmailLayoutProps {
children: ReactNode;
@@ -1,5 +1,4 @@
import type { FC } from "react";
import React, { useEffect, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { Button } from "@plane/propel/button";
@@ -1,12 +1,12 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IInstance, IInstanceAdmin } from "@plane/types";
import { IInstance, IInstanceAdmin } from "@plane/types";
// ui
import { Input, ToggleSwitch } from "@plane/ui";
// components
@@ -1,11 +1,10 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "General Settings - God Mode",
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react";
@@ -1,9 +1,9 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
interface ImageLayoutProps {
children: ReactNode;
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { FC, ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// components
@@ -1,14 +1,13 @@
"use client";
import type { FC } from "react";
import { useState, useRef } from "react";
import { FC, useState, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
@@ -21,7 +20,7 @@ const helpOptions = [
{
name: "Documentation",
href: "https://docs.plane.so/",
Icon: PageIcon,
Icon: FileText,
},
{
name: "Join our Discord",
@@ -111,7 +110,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" width={14} height={14} />
<Icon className="h-3.5 w-3.5 text-custom-text-200" size={14} />
</div>
<span className="text-xs">{name}</span>
</div>
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
@@ -7,7 +7,7 @@ import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constan
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace } from "@plane/types";
import { IWorkspace } from "@plane/types";
// components
import { CustomSelect, Input } from "@plane/ui";
// hooks
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Workspace Management - God Mode",
@@ -8,7 +8,7 @@ import { Loader as LoaderIcon } from "lucide-react";
// types
import { Button, getButtonStyling } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
+2 -2
View File
@@ -1,7 +1,7 @@
import type { FC } from "react";
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import type { TAdminAuthErrorInfo } from "@plane/constants";
import { TAdminAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
+3 -4
View File
@@ -1,11 +1,10 @@
import type { ReactNode } from "react";
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import type { TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
+2 -4
View File
@@ -1,12 +1,10 @@
"use client";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Input, Spinner } from "@plane/ui";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FC, ReactNode } from "react";
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { createContext } from "react";
import { ReactNode, createContext } from "react";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { FC, ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { ReactNode } from "react";
import { 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 type {
import {
TGetBaseAuthenticationModeProps,
TInstanceAuthenticationMethodKeys,
TInstanceAuthenticationModes,
@@ -12,7 +12,6 @@ import { resolveGeneralTheme } from "@plane/utils";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
import { GiteaConfiguration } from "@/components/authentication/gitea-config";
import { GithubConfiguration } from "@/components/authentication/github-config";
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
import { GoogleConfiguration } from "@/components/authentication/google-config";
@@ -20,7 +19,6 @@ import { PasswordLoginConfiguration } from "@/components/authentication/password
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// assets
import giteaLogo from "@/public/logos/gitea-logo.svg";
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
@@ -82,13 +80,6 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitea",
name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
icon: <Image src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "oidc",
name: "OIDC",
@@ -1,6 +1,6 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
// helpers
import { cn } from "@plane/utils";
@@ -3,7 +3,7 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
@@ -1,58 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GiteaConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const GiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
const GiteaConfigured =
!!formattedConfig?.GITEA_HOST && !!formattedConfig?.GITEA_CLIENT_ID && !!formattedConfig?.GITEA_CLIENT_SECRET;
return (
<>
{GiteaConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/gitea" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(GiteaConfig))}
onChange={() => {
Boolean(parseInt(GiteaConfig)) === true
? updateConfig("IS_GITEA_ENABLED", "0")
: updateConfig("IS_GITEA_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/gitea"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
@@ -3,7 +3,7 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import { FC } from "react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
type TBanner = {
@@ -1,8 +1,7 @@
"use client";
import React, { useState } from "react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import { Controller, Control } from "react-hook-form";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
@@ -1,5 +1,5 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
@@ -1,6 +1,6 @@
"use client";
import type { FC } from "react";
import { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@plane/propel/button";
@@ -1,7 +1,6 @@
"use client";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
import { Eye, EyeOff } from "lucide-react";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import type { IInstanceStore } from "@/store/instance.store";
import { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => {
const context = useContext(StoreContext);
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import type { IThemeStore } from "@/store/theme.store";
import { IThemeStore } from "@/store/theme.store";
export const useTheme = (): IThemeStore => {
const context = useContext(StoreContext);
+1 -1
View File
@@ -1,7 +1,7 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/app/(all)/store.provider";
import type { IUserStore } from "@/store/user.store";
import { 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 type { IWorkspaceStore } from "@/store/workspace.store";
import { IWorkspaceStore } from "@/store/workspace.store";
export const useWorkspace = (): IWorkspaceStore => {
const context = useContext(StoreContext);
+3 -4
View File
@@ -1,10 +1,9 @@
import { set } from "lodash-es";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import type { TInstanceStatus } from "@plane/constants";
import { EInstanceStatus } from "@plane/constants";
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import { InstanceService } from "@plane/services";
import type {
import {
IInstance,
IInstanceAdmin,
IInstanceConfiguration,
@@ -13,7 +12,7 @@ import type {
IInstanceConfig,
} from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
+4 -8
View File
@@ -1,13 +1,9 @@
import { enableStaticRendering } from "mobx-react";
// stores
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";
import { IInstanceStore, InstanceStore } from "./instance.store";
import { IThemeStore, ThemeStore } from "./theme.store";
import { IUserStore, UserStore } from "./user.store";
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
enableStaticRendering(typeof window === "undefined");
+1 -1
View File
@@ -1,6 +1,6 @@
import { action, observable, makeObservable } from "mobx";
// root store
import type { CoreRootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
+3 -4
View File
@@ -1,11 +1,10 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import type { TUserStatus } from "@plane/constants";
import { EUserStatus } from "@plane/constants";
import { EUserStatus, TUserStatus } from "@plane/constants";
import { AuthService, UserService } from "@plane/services";
import type { IUser } from "@plane/types";
import { IUser } from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IUserStore {
// observables
+2 -2
View File
@@ -2,9 +2,9 @@ import { set } from "lodash-es";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
// plane imports
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import { CoreRootStore } from "@/store/root.store";
export interface IWorkspaceStore {
// observables
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -46,7 +46,7 @@
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/node": "18.16.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -54
View File
@@ -1,5 +1,4 @@
# Third party imports
import random
from rest_framework import serializers
# Module imports
@@ -25,47 +24,6 @@ class ProjectCreateSerializer(BaseSerializer):
and workspace association for new project initialization.
"""
PROJECT_ICON_DEFAULT_COLORS = [
"#95999f",
"#6d7b8a",
"#5e6ad2",
"#02b5ed",
"#02b55c",
"#f2be02",
"#e57a00",
"#f38e82",
]
PROJECT_ICON_DEFAULT_ICONS = [
"home",
"apps",
"settings",
"star",
"favorite",
"done",
"check_circle",
"add_task",
"create_new_folder",
"dataset",
"terminal",
"key",
"rocket",
"public",
"quiz",
"mood",
"gavel",
"eco",
"diamond",
"forest",
"bolt",
"sync",
"cached",
"library_add",
"view_timeline",
"view_kanban",
"empty_dashboard",
"cycle",
]
class Meta:
model = Project
fields = [
@@ -86,10 +44,10 @@ class ProjectCreateSerializer(BaseSerializer):
"archive_in",
"close_in",
"timezone",
"logo_props",
"external_source",
"external_id",
"is_issue_type_enabled",
"is_time_tracking_enabled",
]
read_only_fields = [
@@ -99,7 +57,6 @@ class ProjectCreateSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"logo_props",
]
def validate(self, data):
@@ -129,16 +86,6 @@ class ProjectCreateSerializer(BaseSerializer):
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
if validated_data.get("logo_props", None) is None:
# Generate a random icon and color for the project icon
validated_data["logo_props"] = {
"in_use": "icon",
"icon": {
"name": random.choice(self.PROJECT_ICON_DEFAULT_ICONS),
"color": random.choice(self.PROJECT_ICON_DEFAULT_COLORS),
},
}
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
return project
+423 -94
View File
@@ -12,7 +12,13 @@ from django.db.models import (
OuterRef,
Q,
Sum,
FloatField,
Case,
When,
Value,
)
from django.db.models.functions import Cast, Concat
from django.db import models
# Third party imports
from rest_framework import status
@@ -41,7 +47,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -195,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,
@@ -254,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),
@@ -302,17 +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, context={"request": request}
)
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -355,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,
)
@@ -505,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(
@@ -520,20 +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, context={"request": request}
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -541,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()
):
@@ -600,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",
@@ -624,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)
@@ -764,9 +742,7 @@ 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(
@@ -785,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"},
@@ -818,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)
@@ -883,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()
@@ -922,9 +892,7 @@ 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(
@@ -954,13 +922,10 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
if not issues:
return Response(
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"},
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(
@@ -972,13 +937,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
)
# 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))
@@ -1029,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()),
@@ -1107,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(
@@ -1214,34 +1171,406 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
old_cycle = Cycle.objects.get(
workspace__slug=slug,
project_id=project_id,
pk=cycle_id,
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)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
# transfer work items only when cycle is completed (passed the end data)
if old_cycle.end_date is not None and old_cycle.end_date > timezone.now():
return Response(
{"error": "The old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"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"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
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(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
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,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return Response(
{"error": result.get("error")},
{"error": "The cycle where the issues are transferred is already completed"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+18 -22
View File
@@ -987,12 +987,12 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
and Label.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_source=request.data.get("external_source", label.external_source),
external_id=request.data.get("external_id"),
)
.exclude(id=pk)
@@ -1695,27 +1695,23 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific activity.
Excludes comment, vote, reaction, and draft activities.
"""
issue_activity = (
(
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id, id=pk)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
issue_activities = (
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,
project__project_projectmember__is_active=True,
)
.order_by(request.GET.get("order_by", "created_at"))
.first()
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
if not issue_activity:
return Response({"message": "Activity not found.", "code": "NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND)
return Response(
IssueActivitySerializer(issue_activity, fields=self.fields, expand=self.expand).data,
status=status.HTTP_200_OK,
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
issue_activity, many=True, fields=self.fields, expand=self.expand
).data,
)
+1 -3
View File
@@ -871,8 +871,6 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
module_id=module_id,
issue_id=issue_id,
)
module_name = module_issue.module.name if module_issue.module is not None else ""
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
@@ -880,7 +878,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps({"module_name": module_name}),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+32 -1
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from plane.db.models.user import get_default_tour_completed_features
from .base import BaseSerializer
@@ -194,6 +194,37 @@ class ResetPasswordSerializer(serializers.Serializer):
class ProfileSerializer(BaseSerializer):
def validate_tour_completed_features(self, value):
"""
Clean tour_completed_features by removing invalid keys and keeping only
the keys present in get_default_tour_completed_features function.
"""
if not isinstance(value, dict):
return get_default_tour_completed_features()
expected_structure = get_default_tour_completed_features()
# Use dict comprehension for cleaner, more efficient code
cleaned_data = {}
# Process valid top-level keys
for key in expected_structure:
if key in value:
val = value[key]
if isinstance(val, dict) and key in ['workspace_features', 'project_features']:
# Clean nested structure using intersection
expected_nested = expected_structure[key]
cleaned_data[key] = {k: v for k, v in val.items() if k in expected_nested}
else:
cleaned_data[key] = val
else:
# Add missing key with default
default_value = expected_structure[key]
cleaned_data[key] = default_value.copy() if isinstance(default_value, dict) else default_value
return cleaned_data
class Meta:
model = Profile
fields = "__all__"
-2
View File
@@ -17,7 +17,6 @@ from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls
urlpatterns = [
*analytic_urls,
@@ -39,5 +38,4 @@ urlpatterns = [
*api_urls,
*webhook_urls,
*timezone_urls,
*exporter_urls,
]
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path
from plane.app.views import ExportIssuesEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
+7
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
@@ -140,6 +141,12 @@ urlpatterns = [
IssueAttachmentV2Endpoint.as_view(),
name="project-issue-attachments",
),
## Export Issues
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
+428 -111
View File
@@ -51,7 +51,6 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
@@ -97,9 +96,7 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar_asset", "first_name", "id"
).distinct(),
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
)
)
.prefetch_related(
@@ -150,8 +147,7 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -170,11 +166,7 @@ class CycleViewSet(BaseViewSet):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
& (
Q(
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
)
),
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -205,9 +197,7 @@ class CycleViewSet(BaseViewSet):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
data = queryset.values(
# necessary fields
@@ -274,16 +264,10 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
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 = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -323,9 +307,7 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
# Send the model activity
model_activity.delay(
@@ -341,17 +323,13 @@ class CycleViewSet(BaseViewSet):
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,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -359,29 +337,21 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
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 = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -481,9 +451,7 @@ class CycleViewSet(BaseViewSet):
)
if data is None:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.first()
# Fetch the project timezone
@@ -505,11 +473,7 @@ class CycleViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
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",
@@ -560,9 +524,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -635,23 +597,409 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.id,
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)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
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_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
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(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Handle error response
if result.get("error"):
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
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,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return Response(
{"error": result["error"]},
{"error": "The cycle where the issues are transferred is already completed"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -666,12 +1014,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
@@ -695,13 +1039,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
if not cycle:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -747,9 +1087,7 @@ class CycleProgressEndpoint(BaseAPIView):
output_field=FloatField(),
)
),
total_estimate_points=Sum(
"value_as_float", default=Value(0), output_field=FloatField()
),
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
)
)
if cycle.progress_snapshot:
@@ -809,22 +1147,11 @@ class CycleProgressEndpoint(BaseAPIView):
return Response(
{
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
or 0,
"unstarted_estimate_points": aggregate_estimates[
"unstarted_estimate_point"
]
or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"]
or 0,
"cancelled_estimate_points": aggregate_estimates[
"cancelled_estimate_point"
]
or 0,
"completed_estimate_points": aggregate_estimates[
"completed_estimate_points"
]
or 0,
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -842,9 +1169,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -927,9 +1252,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "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()),
@@ -964,9 +1287,7 @@ class CycleAnalyticsEndpoint(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()),
@@ -1068,11 +1389,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
)
)
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"label_id",
+8 -17
View File
@@ -137,11 +137,7 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page_transaction.delay(request.data, None, serializer.data["id"])
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,8 +168,11 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
page_id=page_id,
)
@@ -505,11 +504,7 @@ class PagesDescriptionViewSet(BaseViewSet):
if serializer.is_valid():
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
# Update the page using serializer
updated_page = serializer.save()
@@ -555,11 +550,7 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
+83 -158
View File
@@ -43,25 +43,22 @@ class GlobalSearchEndpoint(BaseAPIView):
also show related workspace if found
"""
def filter_workspaces(self, query, _slug, _project_id, _workspace_search):
def filter_workspaces(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Workspace.objects.filter(q, workspace_member__member=self.request.user)
.order_by("-created_at")
.distinct()
.values("name", "id", "slug")
)
def filter_projects(self, query, slug, _project_id, _workspace_search):
def filter_projects(self, query, slug, project_id, workspace_search):
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Project.objects.filter(
q,
@@ -70,7 +67,6 @@ class GlobalSearchEndpoint(BaseAPIView):
archived_at__isnull=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")
)
@@ -78,15 +74,14 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_issues(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = Issue.issue_objects.filter(
q,
@@ -111,9 +106,8 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = Cycle.objects.filter(
q,
@@ -126,20 +120,13 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
cycles = cycles.filter(project_id=project_id)
return (
cycles.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def filter_modules(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = Module.objects.filter(
q,
@@ -152,20 +139,13 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
modules = modules.filter(project_id=project_id)
return (
modules.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def filter_pages(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
@@ -177,9 +157,7 @@ class GlobalSearchEndpoint(BaseAPIView):
)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
Value([], output_field=ArrayField(UUIDField())),
)
)
@@ -196,28 +174,19 @@ class GlobalSearchEndpoint(BaseAPIView):
)
if workspace_search == "false" and project_id:
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=project_id
).values_list("project_id", flat=True)[:1]
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
"project_id", flat=True
)[:1]
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
project_id=project_id
)
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)
return (
pages.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
)
return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
def filter_views(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = IssueView.objects.filter(
q,
@@ -230,57 +199,29 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
issue_views = issue_views.filter(project_id=project_id)
return (
issue_views.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_intakes(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = Issue.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2))
if workspace_search == "false" and project_id:
issues = issues.filter(project_id=project_id)
return (
issues.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)[:100]
)
return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def get(self, request, slug):
query = request.query_params.get("search", False)
entities_param = request.query_params.get("entities")
workspace_search = request.query_params.get("workspace_search", "false")
project_id = request.query_params.get("project_id", False)
if not query:
return Response(
{
"results": {
"workspace": [],
"project": [],
"issue": [],
"cycle": [],
"module": [],
"issue_view": [],
"page": [],
}
},
status=status.HTTP_200_OK,
)
MODELS_MAPPER = {
"workspace": self.filter_workspaces,
"project": self.filter_projects,
@@ -289,27 +230,13 @@ class GlobalSearchEndpoint(BaseAPIView):
"module": self.filter_modules,
"issue_view": self.filter_views,
"page": self.filter_pages,
"intake": self.filter_intakes,
}
# Determine which entities to search
if entities_param:
requested_entities = [
e.strip() for e in entities_param.split(",") if e.strip()
]
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
else:
requested_entities = list(MODELS_MAPPER.keys())
results = {}
for entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(
query or None, slug, project_id, workspace_search
)
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
@@ -367,15 +294,29 @@ class SearchEndpoint(BaseAPIView):
.order_by("-created_at")
)
users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
)
response_data["user_mention"] = list(users[:count])
@@ -389,15 +330,12 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)
@@ -456,20 +394,16 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -587,9 +521,7 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
.values("member__avatar_url", "member__display_name", "member__id")[:count]
)
response_data["user_mention"] = list(users)
@@ -603,15 +535,12 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)
@@ -668,20 +597,16 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -38,11 +38,9 @@ AUTHENTICATION_ERROR_CODES = {
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GITEA_NOT_CONFIGURED": 5112,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
"GITEA_OAUTH_PROVIDER_ERROR": 5123,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -48,8 +48,6 @@ class OauthAdapter(Adapter):
return "GITHUB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitlab":
return "GITLAB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitea":
return "GITEA_OAUTH_PROVIDER_ERROR"
else:
return "OAUTH_NOT_CONFIGURED"
@@ -1,171 +0,0 @@
import os
from datetime import datetime, timedelta
from urllib.parse import urlencode, urlparse
import pytz
import requests
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GiteaOAuthProvider(OauthAdapter):
provider = "gitea"
scope = "openid email profile"
def __init__(self, request, code=None, state=None, callback=None):
(GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value(
[
{
"key": "GITEA_CLIENT_ID",
"default": os.environ.get("GITEA_CLIENT_ID"),
},
{
"key": "GITEA_CLIENT_SECRET",
"default": os.environ.get("GITEA_CLIENT_SECRET"),
},
{
"key": "GITEA_HOST",
"default": os.environ.get("GITEA_HOST"),
},
]
)
if not (GITEA_CLIENT_ID and GITEA_CLIENT_SECRET and GITEA_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"],
error_message="GITEA_NOT_CONFIGURED",
)
# Enforce scheme and normalize trailing slash(es)
parsed = urlparse(GITEA_HOST)
if not parsed.scheme or parsed.scheme not in ("https", "http"):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"],
error_message="GITEA_NOT_CONFIGURED", # avoid leaking details to query params
)
GITEA_HOST = GITEA_HOST.rstrip("/")
# Set URLs based on the host
self.token_url = f"{GITEA_HOST}/login/oauth/access_token"
self.userinfo_url = f"{GITEA_HOST}/api/v1/user"
client_id = GITEA_CLIENT_ID
client_secret = GITEA_CLIENT_SECRET
redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/gitea/callback/"
url_params = {
"client_id": client_id,
"scope": self.scope,
"redirect_uri": redirect_uri,
"response_type": "code",
"state": state,
}
auth_url = f"{GITEA_HOST}/login/oauth/authorize?{urlencode(url_params)}"
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
callback=callback,
)
def set_token_data(self):
data = {
"code": self.code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
headers = {"Accept": "application/json"}
token_response = self.get_user_token(data=data, headers=headers)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("expires_in"))
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"), tz=pytz.utc
)
if token_response.get("refresh_token_expired_at")
else None
),
"id_token": token_response.get("id_token", ""),
}
)
def __get_email(self, headers):
try:
# Gitea may not provide email in user response, so fetch it separately
emails_url = f"{self.userinfo_url}/emails"
response = requests.get(emails_url, headers=headers)
if not response.ok:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: Failed to fetch emails",
)
emails_response = response.json()
if not emails_response:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found",
)
# Prefer primary+verified, then any verified, then primary, else first
email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None)
if not email:
email = next((e.get("email") for e in emails_response if e.get("verified")), None)
if not email:
email = next((e.get("email") for e in emails_response if e.get("primary")), None)
if not email and emails_response:
# If no primary email, use the first one
email = emails_response[0].get("email")
return email
except requests.RequestException:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: Exception occurred while fetching emails",
)
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
# Get email if not provided in user info
email = user_info_response.get("email")
if not email:
email = self.__get_email(headers=headers)
super().set_user_data(
{
"email": email,
"user": {
"provider_id": str(user_info_response.get("id")),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("full_name") or user_info_response.get("login"),
"last_name": "", # Gitea doesn't provide separate first/last name
"is_password_autoset": True,
},
}
)
-17
View File
@@ -36,10 +36,6 @@ from .views import (
SignInAuthSpaceEndpoint,
SignUpAuthSpaceEndpoint,
SignOutAuthSpaceEndpoint,
GiteaCallbackEndpoint,
GiteaOauthInitiateEndpoint,
GiteaCallbackSpaceEndpoint,
GiteaOauthInitiateSpaceEndpoint,
)
urlpatterns = [
@@ -133,17 +129,4 @@ urlpatterns = [
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
## Gitea Oauth
path("gitea/", GiteaOauthInitiateEndpoint.as_view(), name="gitea-initiate"),
path("gitea/callback/", GiteaCallbackEndpoint.as_view(), name="gitea-callback"),
path(
"spaces/gitea/",
GiteaOauthInitiateSpaceEndpoint.as_view(),
name="space-gitea-initiate",
),
path(
"spaces/gitea/callback/",
GiteaCallbackSpaceEndpoint.as_view(),
name="space-gitea-callback",
),
]
@@ -5,7 +5,6 @@ from .app.check import EmailCheckEndpoint
from .app.email import SignInAuthEndpoint, SignUpAuthEndpoint
from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint
from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
@@ -18,8 +17,6 @@ from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceE
from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint
from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint
from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint
from .space.magic import (
@@ -1,109 +0,0 @@
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GiteaOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(validate_next_path(next_path))
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GiteaOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
return HttpResponseRedirect(url)
class GiteaCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(
request=request, code=code, callback=post_user_auth_workflow
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -1,100 +0,0 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
class GiteaOauthInitiateSpaceEndpoint(View):
def get(self, request):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(validate_next_path(next_path))
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GiteaOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
class GiteaCallbackSpaceEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(request=request, code=code)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = (
f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
+1 -2
View File
@@ -410,8 +410,7 @@ def get_webhook_logs_queryset():
"response_headers",
"retry_count",
)
.order_by("created_at")
.iterator(chunk_size=100)
.iterator(chunk_size=BATCH_SIZE)
)
+374 -43
View File
@@ -1,24 +1,82 @@
# Python imports
import csv
import io
import json
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
from datetime import datetime, date
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Prefetch
from openpyxl import Workbook
from django.db.models import F, Prefetch
from collections import defaultdict
# Module imports
from plane.db.models import ExporterHistory, Issue, IssueRelation
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
from plane.utils.exception_logger import log_exception
from plane.utils.exporters import Exporter, IssueExportSchema
def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
return json.dumps(data)
def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
@@ -60,9 +118,7 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
# Generate presigned url for the uploaded file with different base
presign_s3 = boto3.client(
"s3",
endpoint_url=(
f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/"
),
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", # noqa: E501
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
@@ -120,6 +176,187 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
return [
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project_name"],
issue["name"],
issue["description"],
issue["state_name"],
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
issue["created_by"],
", ".join(issue["labels"]) if issue["labels"] else "",
issue["cycle_name"],
issue["cycle_start_date"],
issue["cycle_end_date"],
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
(
", ".join(
[
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
for comment in issue["comments"]
]
)
if issue["comments"]
else ""
),
issue["estimate"] if issue["estimate"] else "",
", ".join(issue["link"]) if issue["link"] else "",
", ".join(issue["assignees"]) if issue["assignees"] else "",
issue["subscribers_count"] if issue["subscribers_count"] else "",
issue["attachment_count"] if issue["attachment_count"] else "",
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
]
def generate_json_row(issue: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
return {
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project_name"],
"Name": issue["name"],
"Description": issue["description"],
"State": issue["state_name"],
"Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"],
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
"Assignee": issue["assignees"],
"Labels": issue["labels"],
"Cycle Name": issue["cycle_name"],
"Cycle Start Date": issue["cycle_start_date"],
"Cycle End Date": issue["cycle_end_date"],
"Module Name": issue["module_name"],
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
"Comments": issue["comments"],
"Estimate": issue["estimate"],
"Link": issue["link"],
"Subscribers Count": issue["subscribers_count"],
"Attachment Count": issue["attachment_count"],
"Attachment Links": issue["attachment_links"],
}
def update_json_row(rows: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row["ID"] == row["ID"]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and (existing_labels is None or label not in existing_labels):
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
rows[matched_index][8] += f", {assignee}"
if label is not None and (existing_labels is None or label not in existing_labels):
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate CSV export for all the passed issues.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate JSON export for all the passed issues.
"""
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate XLSX export for all the passed issues.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
def get_created_by(obj: Issue | IssueComment) -> str:
"""
Get the created by user for the given object.
"""
if obj.created_by:
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
return ""
@shared_task
def issue_export_task(
provider: str,
@@ -140,7 +377,7 @@ def issue_export_task(
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
# Build base queryset for issues
# Base query to get the issues
workspace_issues = (
Issue.objects.filter(
workspace__id=workspace_id,
@@ -153,6 +390,7 @@ def issue_export_task(
"project",
"workspace",
"state",
"parent",
"created_by",
"estimate_point",
)
@@ -162,51 +400,144 @@ def issue_export_task(
"issue_module__module",
"issue_comments",
"assignees",
Prefetch(
"assignees",
queryset=User.objects.only("first_name", "last_name").distinct(),
to_attr="assignee_details",
),
Prefetch(
"labels",
queryset=Label.objects.only("name").distinct(),
to_attr="label_details",
),
"issue_subscribers",
"issue_link",
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
),
Prefetch(
"issue_related",
queryset=IssueRelation.objects.select_related("issue", "issue__project"),
),
Prefetch(
"parent",
queryset=Issue.objects.select_related("type", "project"),
),
)
)
# Create exporter for the specified format
try:
exporter = Exporter(
format_type=provider,
schema_class=IssueExportSchema,
options={"list_joiner": ", "},
)
except ValueError as e:
# Invalid format type
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
return
# Get the attachments for the issues
file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
# Create a dictionary to store the attachments for the issues
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
# Create a list to store the issues data
issues_data = []
# Iterate over the issues
for issue in workspace_issues:
attachments = attachment_dict.get(issue.id, [])
issue_data = {
"id": issue.id,
"project_identifier": issue.project.identifier,
"project_name": issue.project.name,
"project_id": issue.project.id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"description": issue.description_stripped,
"priority": issue.priority,
"start_date": issue.start_date,
"target_date": issue.target_date,
"state_name": issue.state.name if issue.state else None,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"completed_at": issue.completed_at,
"archived_at": issue.archived_at,
"module_name": [module.module.name for module in issue.issue_module.all()],
"created_by": get_created_by(issue),
"labels": [label.name for label in issue.label_details],
"comments": [
{
"comment": comment.comment_stripped,
"created_at": dateConverter(comment.created_at),
"created_by": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.value if issue.estimate_point and issue.estimate_point.value else "",
"link": [link.url for link in issue.issue_link.all()],
"assignees": [f"{assignee.first_name} {assignee.last_name}" for assignee in issue.assignee_details],
"subscribers_count": issue.issue_subscribers.count(),
"attachment_count": len(attachments),
"attachment_links": [
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
for asset in attachments
],
}
# Get Cycles data for the issue
cycle = issue.issue_cycle.last()
if cycle:
# Update cycle data
issue_data["cycle_name"] = cycle.cycle.name
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
else:
issue_data["cycle_name"] = ""
issue_data["cycle_start_date"] = ""
issue_data["cycle_end_date"] = ""
issues_data.append(issue_data)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Start Date",
"Target Date",
"Priority",
"Created By",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Created At",
"Updated At",
"Completed At",
"Archived At",
"Comments",
"Estimate",
"Link",
"Assignees",
"Subscribers Count",
"Attachment Count",
"Attachment Links",
]
# Map the provider to the function
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
# Export each project separately with its own queryset
project_dict = defaultdict(list)
for issue in issues_data:
project_dict[str(issue["project_id"])].append(issue)
for project_id in project_ids:
project_issues = workspace_issues.filter(project_id=project_id)
export_filename = f"{slug}-{project_id}"
filename, content = exporter.export(export_filename, project_issues)
files.append((filename, content))
issues = project_dict.get(str(project_id), [])
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, project_id, issues, files)
else:
# Export all issues in a single file
export_filename = f"{slug}-{workspace_id}"
filename, content = exporter.export(export_filename, workspace_issues)
files.append((filename, content))
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, workspace_id, issues_data, files)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
+43 -105
View File
@@ -1,5 +1,5 @@
# Python imports
import logging
import json
# Django imports
from django.utils import timezone
@@ -7,134 +7,72 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# App imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
COMPONENT_MAP = {
"mention-component": {
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
"extract": lambda m: {
"entity_name": m.get("entity_name"),
"entity_type": None,
"entity_identifier": m.get("entity_identifier"),
},
},
"image-component": {
"attributes": ["id", "src"],
"extract": lambda m: {
"entity_name": "image",
"entity_type": None,
"entity_identifier": m.get("src"),
},
},
}
component_map = {
**COMPONENT_MAP,
}
def extract_all_components(description_html):
"""
Extracts all component types from the HTML value in a single pass.
Returns a dict mapping component_type -> list of extracted entities.
"""
def extract_components(value, tag):
try:
if not description_html:
return {component: [] for component in component_map.keys()}
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
soup = BeautifulSoup(description_html, "html.parser")
results = {}
for component, config in component_map.items():
attributes = config.get("attributes", ["id"])
component_tags = soup.find_all(component)
entities = []
for tag in component_tags:
entity = {attr: tag.get(attr) for attr in attributes}
entities.append(entity)
results[component] = entities
return results
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
return mentions
except Exception:
return {component: [] for component in component_map.keys()}
def get_entity_details(component: str, mention: dict):
"""
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
"""
config = component_map.get(component)
if not config:
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
return config["extract"](mention)
return []
@shared_task
def page_transaction(new_description_html, old_description_html, page_id):
"""
Tracks changes in page content (mentions, embeds, etc.)
and logs them in PageLog for audit and reference.
"""
def page_transaction(new_value, old_value, page_id):
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
old_value = json.loads(old_value) if old_value else {}
new_transactions = []
deleted_transaction_ids = set()
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
old_ids = {m.get("id") for m in old_entities if m.get("id")}
new_ids = {m.get("id") for m in new_entities if m.get("id")}
deleted_transaction_ids.update(old_ids - new_ids)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
for mention in new_entities:
mention_id = mention.get("id")
if not mention_id or (mention_id in old_ids and has_existing_logs):
continue
details = get_entity_details(component, mention)
current_time = timezone.now()
new_transactions.append(
PageLog(
transaction=mention_id,
page_id=page_id,
entity_identifier=details["entity_identifier"],
entity_name=details["entity_name"],
entity_type=details["entity_type"],
workspace_id=page.workspace_id,
created_at=current_time,
updated_at=current_time,
)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+20 -70
View File
@@ -48,8 +48,6 @@ from plane.db.models import (
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
from plane.settings.mongo import MongoConnection
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
@@ -86,56 +84,6 @@ def get_issue_prefetches():
]
def save_webhook_log(
webhook: Webhook,
request_method: str,
request_headers: str,
request_body: str,
response_status: str,
response_headers: str,
response_body: str,
retry_count: int,
event_type: str,
) -> None:
# webhook_logs
mongo_collection = MongoConnection.get_collection("webhook_logs")
log_data = {
"workspace_id": str(webhook.workspace_id),
"webhook": str(webhook.id),
"event_type": str(event_type),
"request_method": str(request_method),
"request_headers": str(request_headers),
"request_body": str(request_body),
"response_status": str(response_status),
"response_headers": str(response_headers),
"response_body": str(response_body),
"retry_count": retry_count,
}
mongo_save_success = False
if mongo_collection is not None:
try:
# insert the log data into the mongo collection
mongo_collection.insert_one(log_data)
logger.info("Webhook log saved successfully to mongo")
mongo_save_success = True
except Exception as e:
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
mongo_save_success = False
# if the mongo save is not successful, save the log data into the database
if not mongo_save_success:
try:
# insert the log data into the database
WebhookLog.objects.create(**log_data)
logger.info("Webhook log saved successfully to database")
except Exception as e:
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
"""
Retrieve and serialize model data based on the event type.
@@ -242,7 +190,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s
msg.send()
logger.info("Email sent successfully.")
except Exception as e:
log_exception(e, warning=True)
log_exception(e)
logger.error(f"Failed to send email: {e}")
@@ -325,30 +273,32 @@ def webhook_send_task(
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
# Log the webhook request
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
response_status=response.status_code,
response_headers=response.headers,
response_body=response.text,
retry_count=self.request.retries,
event_type=event,
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
logger.info(f"Webhook {webhook.id} sent successfully")
except requests.RequestException as e:
# Log the failed webhook request
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=self.request.retries,
event_type=event,
retry_count=str(self.request.retries),
)
logger.error(f"Webhook {webhook.id} failed with error: {e}")
# Retry logic
@@ -171,12 +171,8 @@ def fetch_and_encode_favicon(
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
try:
issue_link = IssueLink.objects.get(id=id)
except IssueLink.DoesNotExist:
logger.warning(f"IssueLink not found for the id {id} and the url {url}")
return
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
+5 -213
View File
@@ -5,11 +5,9 @@ import time
import uuid
from typing import Dict
import logging
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
@@ -27,13 +25,6 @@ from plane.db.models import (
IssueLabel,
IssueSequence,
IssueActivity,
Page,
ProjectPage,
Cycle,
Module,
CycleIssue,
ModuleIssue,
IssueView,
)
logger = logging.getLogger("plane.worker")
@@ -125,33 +116,13 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"group_by": None,
"order_by": "sort_order",
"type": None,
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
"layout": "list",
"calendar_date_range": "",
},
created_by_id=workspace.created_by_id,
)
@@ -234,8 +205,6 @@ def create_project_issues(
project_map: Dict[int, uuid.UUID],
states_map: Dict[int, uuid.UUID],
labels_map: Dict[int, uuid.UUID],
cycles_map: Dict[int, uuid.UUID],
module_map: Dict[int, uuid.UUID],
) -> None:
"""Creates issues and their associated records for each project.
@@ -265,8 +234,6 @@ def create_project_issues(
labels = issue_seed.pop("labels")
project_id = issue_seed.pop("project_id")
state_id = issue_seed.pop("state_id")
cycle_id = issue_seed.pop("cycle_id")
module_ids = issue_seed.pop("module_ids")
issue = Issue.objects.create(
**issue_seed,
@@ -292,7 +259,6 @@ def create_project_issues(
epoch=time.time(),
)
# Create issue labels
for label_id in labels:
IssueLabel.objects.create(
issue=issue,
@@ -302,172 +268,10 @@ def create_project_issues(
created_by_id=workspace.created_by_id,
)
# Create cycle issues
if cycle_id:
CycleIssue.objects.create(
issue=issue,
cycle_id=cycles_map[cycle_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
# Create module issues
if module_ids:
for module_id in module_ids:
ModuleIssue.objects.create(
issue=issue,
module_id=module_map[module_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates pages for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
page_seeds = read_seed_file("pages.json")
if not page_seeds:
return
for page_seed in page_seeds:
page_id = page_seed.pop("id")
page = Page.objects.create(
workspace_id=workspace.id,
is_global=False,
access=page_seed.get("access", Page.PUBLIC_ACCESS),
name=page_seed.get("name"),
description=page_seed.get("description", {}),
description_html=page_seed.get("description_html", "<p></p>"),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
if page_seed.get("project_id") and page_seed.get("type") == "PROJECT":
ProjectPage.objects.create(
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
return
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
# Create cycles
cycle_seeds = read_seed_file("cycles.json")
if not cycle_seeds:
return
cycle_map: Dict[int, uuid.UUID] = {}
for cycle_seed in cycle_seeds:
cycle_id = cycle_seed.pop("id")
project_id = cycle_seed.pop("project_id")
type = cycle_seed.pop("type")
if type == "CURRENT":
start_date = timezone.now()
end_date = start_date + timedelta(days=14)
if type == "UPCOMING":
# Get the last cycle
last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first()
if last_cycle:
start_date = last_cycle.end_date + timedelta(days=1)
end_date = start_date + timedelta(days=14)
else:
start_date = timezone.now() + timedelta(days=14)
end_date = start_date + timedelta(days=14)
cycle = Cycle.objects.create(
**cycle_seed,
start_date=start_date,
end_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
cycle_map[cycle_id] = cycle.id
logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created")
return cycle_map
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates modules for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
module_seeds = read_seed_file("modules.json")
if not module_seeds:
return
module_map: Dict[int, uuid.UUID] = {}
for index, module_seed in enumerate(module_seeds):
module_id = module_seed.pop("id")
project_id = module_seed.pop("project_id")
start_date = timezone.now() + timedelta(days=index * 2)
end_date = start_date + timedelta(days=14)
module = Module.objects.create(
**module_seed,
start_date=start_date,
target_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
)
module_map[module_id] = module.id
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
return module_map
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates views for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
"""
view_seeds = read_seed_file("views.json")
if not view_seeds:
return
for view_seed in view_seeds:
project_id = view_seed.pop("project_id")
IssueView.objects.create(
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
@shared_task
def workspace_seed(workspace_id: uuid.UUID) -> None:
"""Seeds a new workspace with initial project data.
@@ -495,20 +299,8 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
# Create project labels
label_map = create_project_labels(workspace, project_map)
# Create project cycles
cycle_map = create_cycles(workspace, project_map)
# Create project modules
module_map = create_modules(workspace, project_map)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
# create project views
create_views(workspace, project_map)
# create project pages
create_pages(workspace, project_map)
create_project_issues(workspace, project_map, state_map, label_map)
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
return
@@ -0,0 +1,24 @@
# Generated by Django 4.2.24 on 2025-10-08 10:46
from django.db import migrations, models
import plane.db.models.user
class Migration(migrations.Migration):
dependencies = [
('db', '0107_migrate_filters_to_rich_filters'),
]
operations = [
migrations.AddField(
model_name='profile',
name='tour_completed_features',
field=models.JSONField(default=plane.db.models.user.get_default_tour_completed_features),
),
migrations.AlterField(
model_name='exporterhistory',
name='url',
field=models.TextField(blank=True, null=True),
),
]
+1 -1
View File
@@ -42,7 +42,7 @@ class ExporterHistory(BaseModel):
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
url = models.TextField(blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
-15
View File
@@ -273,21 +273,6 @@ class IssueRelationChoices(models.TextChoices):
IMPLEMENTED_BY = "implemented_by", "Implemented By"
# Bidirectional relation pairs: (forward, reverse)
# Defined after class to avoid enum metaclass conflicts
IssueRelationChoices._RELATION_PAIRS = (
("blocked_by", "blocking"),
("relates_to", "relates_to"), # symmetric
("duplicate", "duplicate"), # symmetric
("start_before", "start_after"),
("finish_before", "finish_after"),
("implemented_by", "implements"),
)
# Generate reverse mapping from pairs
IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS}
class IssueRelation(ProjectBaseModel):
issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE)
related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE)
+17
View File
@@ -34,6 +34,20 @@ def get_mobile_default_onboarding():
"workspace_join": False,
}
def get_default_tour_completed_features():
return {
"workspace_features": {
"projects": False
},
"project_features": {
"workitems": False,
"cycles": False,
"modules": False,
"views": False,
"pages": False,
},
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
@@ -214,6 +228,9 @@ class Profile(TimeAuditModel):
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
# product tour features
tour_completed_features = models.JSONField(default=get_default_tour_completed_features)
# marketing
has_marketing_email_consent = models.BooleanField(default=False)
@@ -50,7 +50,6 @@ class InstanceEndpoint(BaseAPIView):
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
IS_GITEA_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -87,10 +86,6 @@ class InstanceEndpoint(BaseAPIView):
"key": "IS_GITLAB_ENABLED",
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
},
{
"key": "IS_GITEA_ENABLED",
"default": os.environ.get("IS_GITEA_ENABLED", "0"),
},
{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
@@ -139,7 +134,6 @@ class InstanceEndpoint(BaseAPIView):
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
@@ -6,7 +6,6 @@ from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import InstanceConfiguration
from plane.utils.instance_config_variables import instance_config_variables
class Command(BaseCommand):
@@ -22,7 +21,175 @@ class Command(BaseCommand):
if not os.environ.get(item):
raise CommandError(f"{item} env variable is required.")
for item in instance_config_variables:
config_keys = [
# Authentication Settings
{
"key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "DISABLE_WORKSPACE_CREATION",
"value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
"category": "WORKSPACE_MANAGEMENT",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_SECRET",
"value": os.environ.get("GOOGLE_CLIENT_SECRET"),
"category": "GOOGLE",
"is_encrypted": True,
},
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_SECRET",
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_ID",
"value": os.environ.get("GITLAB_CLIENT_ID"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "ENABLE_SMTP",
"value": os.environ.get("ENABLE_SMTP", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
"category": "GITLAB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_PASSWORD",
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"category": "SMTP",
"is_encrypted": True,
},
{
"key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_SSL",
"value": os.environ.get("EMAIL_USE_SSL", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "LLM_API_KEY",
"value": os.environ.get("LLM_API_KEY"),
"category": "AI",
"is_encrypted": True,
},
{
"key": "LLM_PROVIDER",
"value": os.environ.get("LLM_PROVIDER", "openai"),
"category": "AI",
"is_encrypted": False,
},
{
"key": "LLM_MODEL",
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
"category": "AI",
"is_encrypted": False,
},
# Deprecated, use LLM_MODEL
{
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
# intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
for item in config_keys:
obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key"))
if created:
obj.category = item.get("category")
@@ -36,7 +203,7 @@ class Command(BaseCommand):
else:
self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists"))
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"]
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -115,34 +282,6 @@ class Command(BaseCommand):
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
if key == "IS_GITEA_ENABLED":
GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITEA_HOST",
"default": os.environ.get("GITEA_HOST", ""),
},
{
"key": "GITEA_CLIENT_ID",
"default": os.environ.get("GITEA_CLIENT_ID", ""),
},
{
"key": "GITEA_CLIENT_SECRET",
"default": os.environ.get("GITEA_CLIENT_SECRET", ""),
},
]
)
if bool(GITEA_HOST) and bool(GITEA_CLIENT_ID) and bool(GITEA_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITEA_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
else:
for key in keys:
self.stdout.write(self.style.WARNING(f"{key} configuration already exists"))
+1
View File
@@ -12,6 +12,7 @@ from rest_framework.request import Request
from plane.utils.ip_address import get_client_ip
from plane.db.models import APIActivityLog
api_logger = logging.getLogger("plane.api.request")
@@ -1,27 +0,0 @@
from django.core.exceptions import RequestDataTooBig
from django.http import JsonResponse
class RequestBodySizeLimitMiddleware:
"""
Middleware to catch RequestDataTooBig exceptions and return
413 Request Entity Too Large instead of 400 Bad Request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
_ = request.body
except RequestDataTooBig:
return JsonResponse(
{
"error": "REQUEST_BODY_TOO_LARGE",
"detail": "The size of the request body exceeds the maximum allowed size.",
},
status=413,
)
# If body size is OK, continue with the request
return self.get_response(request)
-18
View File
@@ -1,18 +0,0 @@
[
{
"id": 1,
"name": "Cycle 1: Getting Started with Plane",
"project_id": 1,
"sort_order": 1,
"timezone": "UTC",
"type": "CURRENT"
},
{
"id": 2,
"name": "Cycle 2: Collaboration & Customization",
"project_id": 1,
"sort_order": 2,
"timezone": "UTC",
"type": "UPCOMING"
}
]
+15 -29
View File
@@ -6,12 +6,10 @@
"description_html": "<p class=\"editor-paragraph-block\">Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.</p><p class=\"editor-paragraph-block\">Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.</p><p class=\"editor-paragraph-block\">First thing to try</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look in the <strong>Properties</strong> section below where it says <strong>State: Todo</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on it and change it to <strong>Done</strong> from the dropdown. Alternatively, you can drag and drop the card to the Done column.</p></li></ol>",
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
"sort_order": 1000,
"state_id": 4,
"state_id": 3,
"labels": [],
"priority": "urgent",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
"priority": "none",
"project_id": 1
},
{
"id": 2,
@@ -21,10 +19,8 @@
"sort_order": 2000,
"state_id": 2,
"labels": [2],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1]
"priority": "none",
"project_id": 1
},
{
"id": 3,
@@ -35,10 +31,8 @@
"sort_order": 3000,
"state_id": 1,
"labels": [],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
"priority": "none",
"project_id": 1
},
{
"id": 4,
@@ -47,12 +41,10 @@
"description_html": "<p class=\"editor-paragraph-block\">A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.</p><p class=\"editor-paragraph-block\">Ready to add something to your project's to-do list? Here's how:</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add work item</strong> button in the top-right corner of the Work Items page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/41.png\" width=\"1085.380859375px\" height=\"482.53758375605696px\" id=\"ba055bc3-4162-4750-9ad4-9434fc0e7121\" aspectratio=\"2.249318801089918\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Give your task a clear title and add any details in the description.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Set up the essentials:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Assign it to a team member (or yourself!)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose a priority level</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add start and due dates if there's a timeline</p></li></ul></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Save time by using the keyboard shortcut <strong>C</strong> from anywhere in your project to quickly create a new work item!</p></div><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to dive deeper into all the things you can do with work items? Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/overview\">documentation</a>.</p>",
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
"sort_order": 4000,
"state_id": 3,
"state_id": 1,
"labels": [2],
"priority": "high",
"project_id": 1,
"cycle_id": 1,
"module_ids": [1, 2]
"priority": "none",
"project_id": 1
},
{
"id": 5,
@@ -61,12 +53,10 @@
"description_html": "<p class=\"editor-paragraph-block\">Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!</p><image-component src=\"https://media.docs.plane.so/seed_assets/51.png\" aspectratio=\"4.489130434782608\"></image-component><h2 class=\"editor-heading-block\">Switch between layouts</h2><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look at the top toolbar in your project. You'll see several layout icons.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click any of these icons to instantly switch between layouts.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/layouts\"><strong>Layouts</strong></a> for more info.</p></div><h2 class=\"editor-heading-block\">Filter and display options</h2><p class=\"editor-paragraph-block\">Need to focus on specific work?</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Filters</strong> dropdown in the toolbar. Select criteria and choose which items to show.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Display</strong> dropdown to tailor how the information appears in your layout</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Created the perfect setup? Save it for later by clicking the the <strong>Save View</strong> button.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Access saved views anytime from the <strong>Views</strong> section in your sidebar.</p></li></ol>",
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
"sort_order": 5000,
"state_id": 3,
"state_id": 1,
"labels": [],
"priority": "none",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2]
"project_id": 1
},
{
"id": 6,
@@ -77,10 +67,8 @@
"sort_order": 6000,
"state_id": 1,
"labels": [2],
"priority": "low",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
"priority": "none",
"project_id": 1
},
{
"id": 7,
@@ -92,8 +80,6 @@
"state_id": 1,
"labels": [],
"priority": "none",
"project_id": 1,
"cycle_id": 2,
"module_ids": [2, 3]
"project_id": 1
}
]

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