Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b4019a69 | ||
|
|
135024a940 | ||
|
|
8500c63205 | ||
|
|
c8736f13ec | ||
|
|
e43b4b3d47 | ||
|
|
59f0e9fe2c | ||
|
|
56956d8786 | ||
|
|
cc455b0e76 | ||
|
|
8705a96220 | ||
|
|
190c85468b | ||
|
|
d3d723cadd | ||
|
|
6828d33c3f | ||
|
|
10e67144a0 | ||
|
|
0e63128d45 | ||
|
|
c9cf7cc631 | ||
|
|
413d6d21b4 | ||
|
|
909fe128f1 | ||
|
|
6a997bb9da | ||
|
|
0dc0a2a8a8 | ||
|
|
e01e736ffe | ||
|
|
ae3dcc3dbd | ||
|
|
c4f5093492 | ||
|
|
cf8053825b | ||
|
|
fc3e63f67a | ||
|
|
c5cac27026 | ||
|
|
072e8213f0 | ||
|
|
5eb8e76b3b | ||
|
|
bd0799f5e7 | ||
|
|
aba2af9a7c | ||
|
|
1028ec8735 | ||
|
|
c99579cddc | ||
|
|
bba10d7073 | ||
|
|
a8f4d21e8b | ||
|
|
c5731ce454 | ||
|
|
8f091b7d7e | ||
|
|
84236f506b | ||
|
|
5bbb796e5e | ||
|
|
59256588db | ||
|
|
2a740b9cd9 | ||
|
|
831a336690 | ||
|
|
244986554c | ||
|
|
0aca5c7a86 | ||
|
|
71c77d30a0 | ||
|
|
c5b1d95c76 | ||
|
|
707c4f9e8d | ||
|
|
fd9f0fb17c | ||
|
|
d1bfed950a | ||
|
|
cfbc0cf2e1 | ||
|
|
299e220d08 | ||
|
|
64bbe19f1b | ||
|
|
92ea5998c5 | ||
|
|
99e1963d9b | ||
|
|
b3626d815f | ||
|
|
9145234a6c | ||
|
|
78d4d981d1 | ||
|
|
c9147e7a57 | ||
|
|
b8ee986636 | ||
|
|
f5f5726e15 | ||
|
|
a72d095e60 | ||
|
|
5183f4439b | ||
|
|
e5d2902a41 | ||
|
|
7f97f234d0 | ||
|
|
645764e53d | ||
|
|
d75e33ccf0 | ||
|
|
ab3a00dd6e | ||
|
|
52617baf0e | ||
|
|
ee4ad580fc | ||
|
|
d9c8271f35 | ||
|
|
e79b0c40ec | ||
|
|
0b4b092eaa | ||
|
|
703aac597c | ||
|
|
d81a476e0b | ||
|
|
ec955e064b | ||
|
|
34e1be7ba0 | ||
|
|
47dfb8797f | ||
|
|
4e5ca88e5e | ||
|
|
bdd43f3f24 | ||
|
|
cb593538e4 | ||
|
|
61d8586f7f | ||
|
|
8071350640 | ||
|
|
d64cc44225 | ||
|
|
a0152cab55 | ||
|
|
aeec219f6d | ||
|
|
3165c3b1d3 | ||
|
|
060fe8ebcc | ||
|
|
7677f021a9 | ||
|
|
64619bf5eb | ||
|
|
cf13ac3116 | ||
|
|
28fc8ccd0e | ||
|
|
eb34dcaaaa | ||
|
|
aa92ace57f | ||
|
|
f4ceaaf01c | ||
|
|
8ccd37d777 | ||
|
|
afe723ee3d | ||
|
|
679db712cf | ||
|
|
3e34699194 | ||
|
|
ee655a045c | ||
|
|
12f101f52a | ||
|
|
e3ebb9b61c | ||
|
|
1c558eb6b3 | ||
|
|
778fca9db6 | ||
|
|
b05579d506 | ||
|
|
cb292b010f | ||
|
|
b5bbdbfa8e | ||
|
|
bdaef179b1 | ||
|
|
59376ee3da | ||
|
|
346bc2afe2 | ||
|
|
a0e16692da | ||
|
|
c24be25024 |
@@ -29,7 +29,7 @@ jobs:
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
Auto_Merge:
|
||||
Create_PR:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- develop
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// types
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
// ui
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGitlabConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/authentication/gitlab"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./email-config-switch";
|
||||
export * from "./password-config-switch";
|
||||
export * from "./authentication-method-card";
|
||||
export * from "./gitlab-config";
|
||||
export * from "./github-config";
|
||||
export * from "./google-config";
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { FC, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
ConfirmDiscardModal,
|
||||
ControllerInput,
|
||||
CopyField,
|
||||
TControllerInputFormField,
|
||||
TCopyField,
|
||||
} from "@/components/common";
|
||||
// helpers
|
||||
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
};
|
||||
|
||||
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGitlabConfigForm: 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<GitlabConfigFormValues>({
|
||||
defaultValues: {
|
||||
GITLAB_HOST: config["GITLAB_HOST"],
|
||||
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
|
||||
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
|
||||
},
|
||||
});
|
||||
|
||||
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
|
||||
{
|
||||
key: "GITLAB_HOST",
|
||||
type: "text",
|
||||
label: "Host",
|
||||
description: (
|
||||
<>
|
||||
This is the <b>GitLab host</b> to use for login, <b>including scheme</b>.
|
||||
</>
|
||||
),
|
||||
placeholder: "https://gitlab.com",
|
||||
error: Boolean(errors.GITLAB_HOST),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "GITLAB_CLIENT_ID",
|
||||
type: "text",
|
||||
label: "Application ID",
|
||||
description: (
|
||||
<>
|
||||
Get this from your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application settings
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
|
||||
error: Boolean(errors.GITLAB_CLIENT_ID),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "GITLAB_CLIENT_SECRET",
|
||||
type: "password",
|
||||
label: "Secret",
|
||||
description: (
|
||||
<>
|
||||
The client secret is also found in your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application settings
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
|
||||
error: Boolean(errors.GITLAB_CLIENT_SECRET),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GITLAB_SERVICE_FIELD: TCopyField[] = [
|
||||
{
|
||||
key: "Callback_URL",
|
||||
label: "Callback URL",
|
||||
url: `${originURL}/auth/gitlab/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into the <b>Redirect URI</b> field of your{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitLab OAuth application
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (formData: GitlabConfigFormValues) => {
|
||||
const payload: Partial<GitlabConfigFormValues> = { ...formData };
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then((response = []) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "GitLab Configuration Settings updated successfully",
|
||||
});
|
||||
reset({
|
||||
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
|
||||
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
|
||||
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_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">
|
||||
<div className="pt-2 text-xl font-medium">Configuration</div>
|
||||
{GITLAB_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("link-neutral", "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 py-4 my-2 bg-custom-background-80/60 rounded-lg">
|
||||
<div className="pt-2 text-xl font-medium">Service provider details</div>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// icons
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
// local components
|
||||
import { AuthenticationMethodCard } from "../components";
|
||||
import { InstanceGitlabConfigForm } from "./form";
|
||||
|
||||
const InstanceGitlabAuthenticationPage = observer(() => {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// config
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
|
||||
await updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Authentication - God Mode" />
|
||||
<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="GitLab"
|
||||
description="Allow members to login or sign up to plane with their GitLab accounts."
|
||||
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableGitlabConfig)) === true
|
||||
? updateConfig("IS_GITLAB_ENABLED", "0")
|
||||
: updateConfig("IS_GITLAB_ENABLED", "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 p-4">
|
||||
{formattedConfig ? (
|
||||
<InstanceGitlabConfigForm 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 InstanceGitlabAuthenticationPage;
|
||||
@@ -17,12 +17,14 @@ import { useInstance } from "@/hooks/store";
|
||||
// images
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
// local components
|
||||
import {
|
||||
AuthenticationMethodCard,
|
||||
EmailCodesConfiguration,
|
||||
PasswordLoginConfiguration,
|
||||
GitlabConfiguration,
|
||||
GithubConfiguration,
|
||||
GoogleConfiguration,
|
||||
} from "./components";
|
||||
@@ -116,6 +118,13 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
),
|
||||
config: <GithubConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to login or sign up to plane with their GitLab accounts.",
|
||||
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,10 +41,10 @@ export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
|
||||
<div
|
||||
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
|
||||
fixed md:relative
|
||||
${isSidebarCollapsed ? "-ml-[280px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[280px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
|
||||
${isSidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
`}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
|
||||
|
||||
@@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
return "Google";
|
||||
case "github":
|
||||
return "Github";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
default:
|
||||
return pathName.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -319,6 +319,8 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
<div className="relative flex items-center pt-2 gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="80 80 220 220"><defs><style>.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style></defs><g id="LOGO"><path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-2" d="M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-3" d="M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z"/><path class="cls-2" d="M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
Regular → Executable
Regular → Executable
@@ -222,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer):
|
||||
class ModuleDetailSerializer(ModuleSerializer):
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
backlog_estimate_points = serializers.IntegerField(read_only=True)
|
||||
unstarted_estimate_points = serializers.IntegerField(read_only=True)
|
||||
started_estimate_points = serializers.IntegerField(read_only=True)
|
||||
cancelled_estimate_points = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
|
||||
|
||||
|
||||
class ModuleUserPropertiesSerializer(BaseSerializer):
|
||||
|
||||
@@ -8,6 +8,8 @@ from plane.db.models import (
|
||||
PageLog,
|
||||
PageLabel,
|
||||
Label,
|
||||
ProjectPage,
|
||||
Project,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +20,15 @@ class PageSerializer(BaseSerializer):
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
project_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
@@ -33,44 +44,53 @@ class PageSerializer(BaseSerializer):
|
||||
"is_locked",
|
||||
"archived_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"view_props",
|
||||
"logo_props",
|
||||
"label_ids",
|
||||
"project_ids",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
data["labels"] = [str(label.id) for label in instance.labels.all()]
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
description_html = self.context["description_html"]
|
||||
|
||||
# Get the workspace id from the project
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
# Create the page
|
||||
page = Page.objects.create(
|
||||
**validated_data,
|
||||
description_html=description_html,
|
||||
project_id=project_id,
|
||||
owned_by_id=owned_by_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
|
||||
# Create the project page
|
||||
ProjectPage.objects.create(
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=project_id,
|
||||
page_id=page.id,
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
# Create page labels
|
||||
if labels is not None:
|
||||
PageLabel.objects.bulk_create(
|
||||
[
|
||||
PageLabel(
|
||||
label=label,
|
||||
page=page,
|
||||
project_id=project_id,
|
||||
workspace_id=page.workspace_id,
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
@@ -90,7 +110,6 @@ class PageSerializer(BaseSerializer):
|
||||
PageLabel(
|
||||
label=label,
|
||||
page=instance,
|
||||
project_id=instance.project_id,
|
||||
workspace_id=instance.workspace_id,
|
||||
created_by_id=instance.created_by_id,
|
||||
updated_by_id=instance.updated_by_id,
|
||||
@@ -120,7 +139,6 @@ class SubPageSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"page",
|
||||
]
|
||||
|
||||
@@ -141,6 +159,5 @@ class PageLogSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"page",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import UnsplashEndpoint
|
||||
from plane.app.views import GPTIntegrationEndpoint
|
||||
from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -16,4 +16,9 @@ urlpatterns = [
|
||||
GPTIntegrationEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/ai-assistant/",
|
||||
WorkspaceGPTIntegrationEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -180,12 +180,14 @@ from .page.base import (
|
||||
PagesDescriptionViewSet,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
from .search.base import GlobalSearchEndpoint
|
||||
from .search.issue import IssueSearchEndpoint
|
||||
|
||||
|
||||
from .external.base import (
|
||||
GPTIntegrationEndpoint,
|
||||
UnsplashEndpoint,
|
||||
WorkspaceGPTIntegrationEndpoint,
|
||||
)
|
||||
from .estimate.base import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
|
||||
@@ -46,6 +46,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
Label,
|
||||
User,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@@ -325,7 +326,6 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
||||
# Update the order by
|
||||
@@ -373,8 +373,108 @@ class CycleViewSet(BaseViewSet):
|
||||
"status",
|
||||
"created_by",
|
||||
)
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
if data:
|
||||
data[0]["estimate_distribution"] = {}
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data[0]["estimate_distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if data[0]["start_date"] and data[0]["end_date"]:
|
||||
data[0]["estimate_distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=data[0]["id"],
|
||||
)
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
@@ -388,7 +488,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@@ -427,8 +530,11 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@@ -464,7 +570,7 @@ class CycleViewSet(BaseViewSet):
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
cycle_id=data[0]["id"],
|
||||
)
|
||||
)
|
||||
@@ -659,7 +765,6 @@ class CycleViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
queryset = (
|
||||
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||
)
|
||||
@@ -710,12 +815,107 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
queryset = queryset.first()
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
data["estimate_distribution"] = {}
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if data["start_date"] and data["end_date"]:
|
||||
data["estimate_distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=pk,
|
||||
)
|
||||
)
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
@@ -738,7 +938,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@@ -778,7 +981,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@@ -815,7 +1021,7 @@ class CycleViewSet(BaseViewSet):
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
cycle_id=pk,
|
||||
)
|
||||
|
||||
@@ -932,7 +1138,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
|
||||
if not new_cycle_id:
|
||||
return Response(
|
||||
@@ -1009,14 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
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.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
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["avatar"],
|
||||
"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.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
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.first(),
|
||||
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 = (
|
||||
@@ -1032,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@@ -1086,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@@ -1112,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar": item["avatar"],
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Label distribution serilization
|
||||
label_distribution_data = [
|
||||
{
|
||||
@@ -1141,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle.first(),
|
||||
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()
|
||||
@@ -1157,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
"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"])
|
||||
|
||||
|
||||
@@ -570,7 +570,6 @@ def dashboard_recent_collaborators(self, request, slug):
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=project_members_with_activities,
|
||||
controller=lambda qs: self.get_results_controller(qs, slug),
|
||||
|
||||
@@ -62,12 +62,15 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request, slug, project_id):
|
||||
estimate = request.data.get('estimate')
|
||||
estimate = request.data.get("estimate")
|
||||
estimate_name = estimate.get("name", generate_random_name())
|
||||
estimate_type = estimate.get("type", 'categories')
|
||||
estimate_type = estimate.get("type", "categories")
|
||||
last_used = estimate.get("last_used", False)
|
||||
estimate = Estimate.objects.create(
|
||||
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
|
||||
name=estimate_name,
|
||||
project_id=project_id,
|
||||
last_used=last_used,
|
||||
type=estimate_type,
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
@@ -125,8 +128,12 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
if request.data.get("estimate"):
|
||||
estimate.name = request.data.get("estimate").get("name", estimate.name)
|
||||
estimate.type = request.data.get("estimate").get("type", estimate.type)
|
||||
estimate.name = request.data.get("estimate").get(
|
||||
"name", estimate.name
|
||||
)
|
||||
estimate.type = request.data.get("estimate").get(
|
||||
"type", estimate.type
|
||||
)
|
||||
estimate.save()
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
@@ -204,7 +211,9 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
serializer = EstimatePointSerializer(estimate_point).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
def partial_update(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
# TODO: add a key validation if the same key already exists
|
||||
estimate_point = EstimatePoint.objects.get(
|
||||
pk=estimate_point_id,
|
||||
@@ -225,7 +234,7 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
def destroy(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
new_estimate_id = request.GET.get("new_estimate_id", None)
|
||||
new_estimate_id = request.data.get("new_estimate_id", None)
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
@@ -236,8 +245,8 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
_ = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_id=estimate_point_id,
|
||||
).update(estimate_id=new_estimate_id)
|
||||
estimate_point_id=estimate_point_id,
|
||||
).update(estimate_point_id=new_estimate_id)
|
||||
|
||||
# delete the estimate point
|
||||
old_estimate_point = EstimatePoint.objects.filter(
|
||||
|
||||
+59
-1
@@ -11,7 +11,7 @@ from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.app.serializers import (
|
||||
ProjectLiteSerializer,
|
||||
@@ -83,6 +83,64 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Get the configuration value
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
|
||||
client = OpenAI(
|
||||
api_key=OPENAI_API_KEY,
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=GPT_ENGINE,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class UnsplashEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
|
||||
|
||||
@@ -12,8 +12,9 @@ from django.db.models import (
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
Sum
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
@@ -25,7 +26,7 @@ from plane.app.permissions import (
|
||||
from plane.app.serializers import (
|
||||
ModuleDetailSerializer,
|
||||
)
|
||||
from plane.db.models import Issue, Module, ModuleLink, UserFavorite
|
||||
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
@@ -165,7 +166,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
if pk is None:
|
||||
queryset = self.get_queryset()
|
||||
modules = queryset.values( # Required fields
|
||||
@@ -218,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
modules = queryset.first()
|
||||
|
||||
data["estimate_distribution"] = {}
|
||||
|
||||
if estimate_type:
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"]["assignee"] = assignee_distribution
|
||||
data["estimate_distribution"]["label"] = label_distribution
|
||||
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["estimate_distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
module_id=pk,
|
||||
)
|
||||
)
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
@@ -310,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
@@ -318,13 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
|
||||
@@ -157,6 +157,62 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
.values("total_estimate_points")[:1]
|
||||
)
|
||||
backlog_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="backlog",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
backlog_estimate_point=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.values("backlog_estimate_point")[:1]
|
||||
)
|
||||
unstarted_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="unstarted",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
unstarted_estimate_point=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.values("unstarted_estimate_point")[:1]
|
||||
)
|
||||
started_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="started",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
started_estimate_point=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.values("started_estimate_point")[:1]
|
||||
)
|
||||
cancelled_estimate_point = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
state__group="cancelled",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(
|
||||
cancelled_estimate_point=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
)
|
||||
)
|
||||
.values("cancelled_estimate_point")[:1]
|
||||
)
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -211,6 +267,30 @@ class ModuleViewSet(BaseViewSet):
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_estimate_points=Coalesce(
|
||||
Subquery(backlog_estimate_point),
|
||||
Value(0, output_field=IntegerField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
unstarted_estimate_points=Coalesce(
|
||||
Subquery(unstarted_estimate_point),
|
||||
Value(0, output_field=IntegerField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
started_estimate_points=Coalesce(
|
||||
Subquery(started_estimate_point),
|
||||
Value(0, output_field=IntegerField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
cancelled_estimate_points=Coalesce(
|
||||
Subquery(cancelled_estimate_point),
|
||||
Value(0, output_field=IntegerField()),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimate_points=Coalesce(
|
||||
Subquery(completed_estimate_point),
|
||||
@@ -346,7 +426,6 @@ class ModuleViewSet(BaseViewSet):
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
plot_type = request.GET.get("plot_type", "burndown")
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=True)
|
||||
@@ -363,6 +442,116 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
modules = queryset.first()
|
||||
|
||||
data["estimate_distribution"] = {}
|
||||
|
||||
if estimate_type:
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField())
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", IntegerField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data["estimate_distribution"]["assignees"] = assignee_distribution
|
||||
data["estimate_distribution"]["labels"] = label_distribution
|
||||
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["estimate_distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
module_id=pk,
|
||||
)
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
@@ -388,7 +577,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@@ -455,21 +644,17 @@ class ModuleViewSet(BaseViewSet):
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,15 +6,19 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import (
|
||||
PageLogSerializer,
|
||||
@@ -27,6 +31,7 @@ from plane.db.models import (
|
||||
PageLog,
|
||||
UserFavorite,
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
@@ -66,28 +71,53 @@ class PageViewSet(BaseViewSet):
|
||||
user=self.request.user,
|
||||
entity_type="page",
|
||||
entity_identifier=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__archived_at__isnull=True,
|
||||
)
|
||||
.filter(parent__isnull=True)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.prefetch_related("projects")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.prefetch_related("labels")
|
||||
.order_by("-is_favorite", "-created_at")
|
||||
.annotate(
|
||||
project=Exists(
|
||||
ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"page_labels__label_id",
|
||||
distinct=True,
|
||||
filter=~Q(page_labels__label_id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.filter(project=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -107,7 +137,7 @@ class PageViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page = Page.objects.get(pk=serializer.data["id"])
|
||||
page = self.get_queryset().get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -115,7 +145,9 @@ class PageViewSet(BaseViewSet):
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
)
|
||||
|
||||
if page.is_locked:
|
||||
@@ -127,7 +159,9 @@ class PageViewSet(BaseViewSet):
|
||||
parent = request.data.get("parent", None)
|
||||
if parent:
|
||||
_ = Page.objects.get(
|
||||
pk=parent, workspace__slug=slug, project_id=project_id
|
||||
pk=parent,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
)
|
||||
|
||||
# Only update access if the page owner is the requesting user
|
||||
@@ -187,7 +221,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = True
|
||||
@@ -196,7 +230,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = False
|
||||
@@ -211,7 +245,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can archive the page
|
||||
@@ -238,7 +272,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can un archive the page
|
||||
@@ -267,7 +301,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
# only the owner and admin can delete the page
|
||||
@@ -293,7 +327,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
# remove parent from all the children
|
||||
_ = Page.objects.filter(
|
||||
parent_id=pk, project_id=project_id, workspace__slug=slug
|
||||
parent_id=pk, projects__id=project_id, workspace__slug=slug
|
||||
).update(parent=None)
|
||||
|
||||
page.delete()
|
||||
@@ -380,7 +414,6 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
pages = (
|
||||
PageLog.objects.filter(
|
||||
page_id=page_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_name__in=["forward_link", "back_link"],
|
||||
)
|
||||
@@ -399,7 +432,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
binary_data = page.description_binary
|
||||
|
||||
@@ -419,7 +452,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
@@ -240,6 +240,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
).first()
|
||||
|
||||
if project is None:
|
||||
return Response(
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
@@ -18,8 +21,8 @@ from plane.db.models import (
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
)
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class GlobalSearchEndpoint(BaseAPIView):
|
||||
@@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = Page.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
project_identifiers=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__identifier",
|
||||
distinct=True,
|
||||
filter=~Q(projects__id=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(CharField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if workspace_search == "false" and project_id:
|
||||
pages = pages.filter(project_id=project_id)
|
||||
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)
|
||||
|
||||
return pages.distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"project_ids",
|
||||
"project_identifiers",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
@@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
func = MODELS_MAPPER.get(model, None)
|
||||
results[model] = func(query, slug, project_id, workspace_search)
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
query = request.query_params.get("search", False)
|
||||
workspace_search = request.query_params.get(
|
||||
"workspace_search", "false"
|
||||
)
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(issue_related__issue=issue),
|
||||
~Q(issue_relation__related_issue=issue),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
"id",
|
||||
"start_date",
|
||||
"sequence_id",
|
||||
"project__name",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
Cycle,
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
)
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
query = request.query_params.get("search", False)
|
||||
workspace_search = request.query_params.get(
|
||||
"workspace_search", "false"
|
||||
)
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
target_date = request.query_params.get("target_date", True)
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
|
||||
if workspace_search == "false":
|
||||
issues = issues.filter(project_id=project_id)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
|
||||
)
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(issue_related__issue=issue),
|
||||
~Q(issue_relation__related_issue=issue),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
if target_date == "none":
|
||||
issues = issues.filter(target_date__isnull=True)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
"id",
|
||||
"start_date",
|
||||
"sequence_id",
|
||||
"project__name",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
|
||||
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
|
||||
# Oauth
|
||||
"OAUTH_NOT_CONFIGURED": 5104,
|
||||
"GOOGLE_NOT_CONFIGURED": 5105,
|
||||
"GITHUB_NOT_CONFIGURED": 5110,
|
||||
"GITLAB_NOT_CONFIGURED": 5111,
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
|
||||
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
|
||||
# Reset Password
|
||||
"INVALID_PASSWORD_TOKEN": 5125,
|
||||
"EXPIRED_PASSWORD_TOKEN": 5130,
|
||||
|
||||
@@ -62,11 +62,7 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
code = (
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "google"
|
||||
else "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
)
|
||||
code = self._provider_error_code()
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||
error_message=str(code),
|
||||
@@ -83,8 +79,12 @@ class OauthAdapter(Adapter):
|
||||
except requests.RequestException:
|
||||
if self.provider == "google":
|
||||
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "github":
|
||||
elif self.provider == "github":
|
||||
code = "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
elif self.provider == "gitlab":
|
||||
code = "GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
else:
|
||||
code = "OAUTH_NOT_CONFIGURED"
|
||||
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||
|
||||
@@ -48,7 +48,7 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
|
||||
error_message="SMTP_NOT_CONFIGURED",
|
||||
payload={"email": str(self.key)},
|
||||
payload={"email": str(key)},
|
||||
)
|
||||
|
||||
if ENABLE_MAGIC_LINK_LOGIN == "0":
|
||||
@@ -57,7 +57,7 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
"MAGIC_LINK_LOGIN_DISABLED"
|
||||
],
|
||||
error_message="MAGIC_LINK_LOGIN_DISABLED",
|
||||
payload={"email": str(self.key)},
|
||||
payload={"email": str(key)},
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Python imports
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytz
|
||||
|
||||
# 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 (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOAuthProvider(OauthAdapter):
|
||||
|
||||
provider = "gitlab"
|
||||
scope = "read_user"
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
|
||||
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"default": os.environ.get("GITLAB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITLAB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_HOST", "https://gitlab.com"
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
self.host = GITLAB_HOST
|
||||
self.token_url = f"{self.host}/oauth/token"
|
||||
self.userinfo_url = f"{self.host}/api/v4/user"
|
||||
|
||||
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
|
||||
error_message="GITLAB_NOT_CONFIGURED",
|
||||
)
|
||||
|
||||
client_id = GITLAB_CLIENT_ID
|
||||
client_secret = GITLAB_CLIENT_SECRET
|
||||
|
||||
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
|
||||
url_params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": self.scope,
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{self.host}/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 = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": self.code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
token_response = self.get_user_token(
|
||||
data=data, headers={"Accept": "application/json"}
|
||||
)
|
||||
super().set_token_data(
|
||||
{
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(
|
||||
token_response.get("created_at")
|
||||
+ token_response.get("expires_in"),
|
||||
tz=pytz.utc,
|
||||
)
|
||||
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 set_user_data(self):
|
||||
user_info_response = self.get_user_response()
|
||||
email = user_info_response.get("email")
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": email,
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -8,6 +8,8 @@ from .views import (
|
||||
ChangePasswordEndpoint,
|
||||
# App
|
||||
EmailCheckEndpoint,
|
||||
GitLabCallbackEndpoint,
|
||||
GitLabOauthInitiateEndpoint,
|
||||
GitHubCallbackEndpoint,
|
||||
GitHubOauthInitiateEndpoint,
|
||||
GoogleCallbackEndpoint,
|
||||
@@ -22,6 +24,8 @@ from .views import (
|
||||
ResetPasswordSpaceEndpoint,
|
||||
# Space
|
||||
EmailCheckSpaceEndpoint,
|
||||
GitLabCallbackSpaceEndpoint,
|
||||
GitLabOauthInitiateSpaceEndpoint,
|
||||
GitHubCallbackSpaceEndpoint,
|
||||
GitHubOauthInitiateSpaceEndpoint,
|
||||
GoogleCallbackSpaceEndpoint,
|
||||
@@ -151,6 +155,27 @@ urlpatterns = [
|
||||
GitHubCallbackSpaceEndpoint.as_view(),
|
||||
name="github-callback",
|
||||
),
|
||||
## Gitlab Oauth
|
||||
path(
|
||||
"gitlab/",
|
||||
GitLabOauthInitiateEndpoint.as_view(),
|
||||
name="gitlab-initiate",
|
||||
),
|
||||
path(
|
||||
"gitlab/callback/",
|
||||
GitLabCallbackEndpoint.as_view(),
|
||||
name="gitlab-callback",
|
||||
),
|
||||
path(
|
||||
"spaces/gitlab/",
|
||||
GitLabOauthInitiateSpaceEndpoint.as_view(),
|
||||
name="gitlab-initiate",
|
||||
),
|
||||
path(
|
||||
"spaces/gitlab/callback/",
|
||||
GitLabCallbackSpaceEndpoint.as_view(),
|
||||
name="gitlab-callback",
|
||||
),
|
||||
# Email Check
|
||||
path(
|
||||
"email-check/",
|
||||
|
||||
@@ -14,6 +14,10 @@ from .app.github import (
|
||||
GitHubCallbackEndpoint,
|
||||
GitHubOauthInitiateEndpoint,
|
||||
)
|
||||
from .app.gitlab import (
|
||||
GitLabCallbackEndpoint,
|
||||
GitLabOauthInitiateEndpoint,
|
||||
)
|
||||
from .app.google import (
|
||||
GoogleCallbackEndpoint,
|
||||
GoogleOauthInitiateEndpoint,
|
||||
@@ -34,6 +38,11 @@ from .space.github import (
|
||||
GitHubOauthInitiateSpaceEndpoint,
|
||||
)
|
||||
|
||||
from .space.gitlab import (
|
||||
GitLabCallbackSpaceEndpoint,
|
||||
GitLabOauthInitiateSpaceEndpoint,
|
||||
)
|
||||
|
||||
from .space.google import (
|
||||
GoogleCallbackSpaceEndpoint,
|
||||
GoogleOauthInitiateSpaceEndpoint,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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.gitlab import GitLabOAuthProvider
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(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(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(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
state = uuid.uuid4().hex
|
||||
provider = GitLabOAuthProvider(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 = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class GitLabCallbackEndpoint(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[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_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[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_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)
|
||||
|
||||
try:
|
||||
provider = GitLabOAuthProvider(
|
||||
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 = 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(next_path)
|
||||
url = urljoin(
|
||||
base_host,
|
||||
"?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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.gitlab import GitLabOAuthProvider
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(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(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(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
state = uuid.uuid4().hex
|
||||
provider = GitLabOAuthProvider(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 GitLabCallbackSpaceEndpoint(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[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.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)
|
||||
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR"
|
||||
],
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.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)
|
||||
|
||||
try:
|
||||
provider = GitLabOAuthProvider(
|
||||
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(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(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -278,7 +278,6 @@ def create_page_labels(workspace, project, user_id, pages_count):
|
||||
PageLabel(
|
||||
page_id=page,
|
||||
label_id=label,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -69,26 +69,34 @@ def create_zip_file(files):
|
||||
|
||||
|
||||
def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
file_name = (
|
||||
f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
|
||||
)
|
||||
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
|
||||
expires_in = 7 * 24 * 60 * 60
|
||||
|
||||
if settings.USE_MINIO:
|
||||
s3 = boto3.client(
|
||||
upload_s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
s3.upload_fileobj(
|
||||
upload_s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
|
||||
# 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', '')}/",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
presigned_url = presign_s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
@@ -96,19 +104,27 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
# Create the new url with updated domain and protocol
|
||||
presigned_url = presigned_url.replace(
|
||||
f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/",
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
# If endpoint url is present, use it
|
||||
if settings.AWS_S3_ENDPOINT_URL:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
# Upload the file to S3
|
||||
s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
@@ -116,6 +132,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
|
||||
# Generate presigned url for the uploaded file
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={
|
||||
@@ -127,6 +144,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
||||
# Update the exporter instance with the presigned url
|
||||
if presigned_url:
|
||||
exporter_instance.url = presigned_url
|
||||
exporter_instance.status = "completed"
|
||||
|
||||
@@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id):
|
||||
entity_identifier=mention["entity_identifier"],
|
||||
entity_name=mention["entity_name"],
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=page.project_id,
|
||||
created_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# Generated by Django 4.2.11 on 2024-06-07 12:04
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
def migrate_pages(apps, schema_editor):
|
||||
ProjectPage = apps.get_model("db", "ProjectPage")
|
||||
Page = apps.get_model("db", "Page")
|
||||
ProjectPage.objects.bulk_create(
|
||||
[
|
||||
ProjectPage(
|
||||
workspace_id=page.get("workspace_id"),
|
||||
project_id=page.get("project_id"),
|
||||
page_id=page.get("id"),
|
||||
created_by_id=page.get("created_by_id"),
|
||||
updated_by_id=page.get("updated_by_id"),
|
||||
)
|
||||
for page in Page.objects.values(
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
)
|
||||
],
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0067_issue_estimate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="is_global",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectPage",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Created At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"page",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_pages",
|
||||
to="db.page",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_pages",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_pages",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Project Page",
|
||||
"verbose_name_plural": "Project Pages",
|
||||
"db_table": "project_pages",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("project", "page")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TeamPage",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Created At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"page",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="team_pages",
|
||||
to="db.page",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="team_pages",
|
||||
to="db.team",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="team_pages",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Team Page",
|
||||
"verbose_name_plural": "Team Pages",
|
||||
"db_table": "team_pages",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("team", "page")},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="projects",
|
||||
field=models.ManyToManyField(
|
||||
related_name="pages", through="db.ProjectPage", to="db.project"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="teams",
|
||||
field=models.ManyToManyField(
|
||||
related_name="pages", through="db.TeamPage", to="db.team"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_pages),
|
||||
migrations.RemoveField(
|
||||
model_name="page",
|
||||
name="project",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="page",
|
||||
name="workspace",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="pages",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="pagelabel",
|
||||
name="project",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="pagelog",
|
||||
name="project",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="pagelabel",
|
||||
name="workspace",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_page_label",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="pagelog",
|
||||
name="workspace",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_page_log",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.11 on 2024-06-03 17:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='provider',
|
||||
field=models.CharField(choices=[('google', 'Google'), ('github', 'Github'), ('gitlab', 'GitLab')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='socialloginconnection',
|
||||
name='medium',
|
||||
field=models.CharField(choices=[('Google', 'google'), ('Github', 'github'), ('GitLab', 'gitlab'), ('Jira', 'jira')], default=None, max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -50,7 +50,7 @@ from .notification import (
|
||||
Notification,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
from .page import Page, PageFavorite, PageLabel, PageLog
|
||||
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
|
||||
from .project import (
|
||||
Project,
|
||||
ProjectBaseModel,
|
||||
|
||||
@@ -9,13 +9,17 @@ from django.db import models
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
from .project import ProjectBaseModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_view_props():
|
||||
return {"full_width": False}
|
||||
|
||||
|
||||
class Page(ProjectBaseModel):
|
||||
class Page(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="pages"
|
||||
)
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
description = models.JSONField(default=dict, blank=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
@@ -44,6 +48,13 @@ class Page(ProjectBaseModel):
|
||||
is_locked = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=get_view_props)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
is_global = models.BooleanField(default=False)
|
||||
projects = models.ManyToManyField(
|
||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||
)
|
||||
teams = models.ManyToManyField(
|
||||
"db.Team", related_name="pages", through="db.TeamPage"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@@ -56,7 +67,7 @@ class Page(ProjectBaseModel):
|
||||
return f"{self.owned_by.email} <{self.name}>"
|
||||
|
||||
|
||||
class PageLog(ProjectBaseModel):
|
||||
class PageLog(BaseModel):
|
||||
TYPE_CHOICES = (
|
||||
("to_do", "To Do"),
|
||||
("issue", "issue"),
|
||||
@@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel):
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name="Transaction Type",
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["page", "transaction"]
|
||||
@@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel):
|
||||
return f"{self.user.email} <{self.page.name}>"
|
||||
|
||||
|
||||
class PageLabel(ProjectBaseModel):
|
||||
class PageLabel(BaseModel):
|
||||
label = models.ForeignKey(
|
||||
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_page_label",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Label"
|
||||
@@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.label.name}"
|
||||
|
||||
|
||||
class ProjectPage(BaseModel):
|
||||
project = models.ForeignKey(
|
||||
"db.Project", on_delete=models.CASCADE, related_name="project_pages"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="project_pages"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="project_pages"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "page"]
|
||||
verbose_name = "Project Page"
|
||||
verbose_name_plural = "Project Pages"
|
||||
db_table = "project_pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project.name} {self.page.name}"
|
||||
|
||||
|
||||
class TeamPage(BaseModel):
|
||||
team = models.ForeignKey(
|
||||
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team", "page"]
|
||||
verbose_name = "Team Page"
|
||||
verbose_name_plural = "Team Pages"
|
||||
db_table = "team_pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@@ -10,7 +10,7 @@ from .base import BaseModel
|
||||
class SocialLoginConnection(BaseModel):
|
||||
medium = models.CharField(
|
||||
max_length=20,
|
||||
choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")),
|
||||
choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")),
|
||||
default=None,
|
||||
)
|
||||
last_login_at = models.DateTimeField(default=timezone.now, null=True)
|
||||
|
||||
@@ -182,7 +182,7 @@ class Account(TimeAuditModel):
|
||||
)
|
||||
provider_account_id = models.CharField(max_length=255)
|
||||
provider = models.CharField(
|
||||
choices=(("google", "Google"), ("github", "Github")),
|
||||
choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
|
||||
)
|
||||
access_token = models.TextField()
|
||||
access_token_expired_at = models.DateTimeField(null=True)
|
||||
|
||||
@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
IS_GOOGLE_ENABLED,
|
||||
IS_GITHUB_ENABLED,
|
||||
GITHUB_APP_NAME,
|
||||
IS_GITLAB_ENABLED,
|
||||
EMAIL_HOST,
|
||||
ENABLE_MAGIC_LINK_LOGIN,
|
||||
ENABLE_EMAIL_PASSWORD,
|
||||
@@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView):
|
||||
"key": "GITHUB_APP_NAME",
|
||||
"default": os.environ.get("GITHUB_APP_NAME", ""),
|
||||
},
|
||||
{
|
||||
"key": "IS_GITLAB_ENABLED",
|
||||
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"default": os.environ.get("EMAIL_HOST", ""),
|
||||
@@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
# Authentication
|
||||
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_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
|
||||
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import InstanceConfiguration
|
||||
@@ -15,6 +15,12 @@ class Command(BaseCommand):
|
||||
from plane.license.utils.encryption import encrypt_data
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
mandatory_keys = ["SECRET_KEY"]
|
||||
|
||||
for item in mandatory_keys:
|
||||
if not os.environ.get(item):
|
||||
raise CommandError(f"{item} env variable is required.")
|
||||
|
||||
config_keys = [
|
||||
# Authentication Settings
|
||||
{
|
||||
@@ -59,6 +65,24 @@ class Command(BaseCommand):
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"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": "GITLAB_CLIENT_SECRET",
|
||||
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
|
||||
"category": "GITLAB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "EMAIL_HOST",
|
||||
"value": os.environ.get("EMAIL_HOST", ""),
|
||||
@@ -145,7 +169,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
|
||||
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_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":
|
||||
@@ -216,6 +240,46 @@ class Command(BaseCommand):
|
||||
f"{key} loaded with value from environment variable."
|
||||
)
|
||||
)
|
||||
if key == "IS_GITLAB_ENABLED":
|
||||
GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_HOST", "https://gitlab.com"
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_ID",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_CLIENT_ID", ""
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_CLIENT_SECRET",
|
||||
"default": os.environ.get(
|
||||
"GITLAB_CLIENT_SECRET", ""
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
|
||||
value = "1"
|
||||
else:
|
||||
value = "0"
|
||||
InstanceConfiguration.objects.create(
|
||||
key="IS_GITLAB_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(
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Prefetch,
|
||||
Case,
|
||||
When,
|
||||
CharField,
|
||||
IntegerField,
|
||||
Value,
|
||||
Max,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -30,19 +43,11 @@ from plane.db.models import (
|
||||
DeployBoard,
|
||||
IssueVote,
|
||||
ProjectPublicMember,
|
||||
State,
|
||||
Label,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
@@ -430,17 +435,14 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
return IssueVote.objects.none()
|
||||
|
||||
def create(self, request, anchor, issue_id):
|
||||
print("hite")
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
print("awer")
|
||||
issue_vote, _ = IssueVote.objects.get_or_create(
|
||||
actor_id=request.user.id,
|
||||
project_id=project_deploy_board.project_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
print("AWer")
|
||||
# Add the user for workspace tracking
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_deploy_board.project_id,
|
||||
@@ -519,24 +521,32 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def get(self, request, anchor):
|
||||
if not DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).exists():
|
||||
).first()
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Project is not published"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
project_id = project_deploy_board.entity_identifier
|
||||
slug = project_deploy_board.workspace.slug
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
project_id = deploy_board.entity_identifier
|
||||
slug = deploy_board.workspace.slug
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
@@ -546,8 +556,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=project_deploy_board.project_id)
|
||||
.filter(workspace_id=project_deploy_board.workspace_id)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
@@ -564,6 +574,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -578,118 +589,107 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
).distinct()
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset,
|
||||
group_by=group_by,
|
||||
sub_group_by=sub_group_by,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by,
|
||||
issues=issues,
|
||||
sub_group_by=sub_group_by,
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by,
|
||||
issues=issues,
|
||||
sub_group_by=sub_group_by,
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
)
|
||||
else:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssuePublicSerializer(issue_queryset, many=True).data
|
||||
|
||||
state_group_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
states = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(
|
||||
custom_order=Case(
|
||||
*[
|
||||
When(group=value, then=Value(index))
|
||||
for index, value in enumerate(state_group_order)
|
||||
],
|
||||
default=Value(len(state_group_order)),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.values("name", "group", "color", "id")
|
||||
.order_by("custom_order", "sequence")
|
||||
)
|
||||
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("id", "name", "color", "parent")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issues": issues,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,9 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def get(self, request, anchor):
|
||||
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).values_list
|
||||
projects = (
|
||||
Project.objects.filter(workspace=deploy_board.workspace)
|
||||
.annotate(
|
||||
|
||||
@@ -10,14 +10,13 @@ from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
def log_exception(e):
|
||||
print(e)
|
||||
# Log the error
|
||||
logger = logging.getLogger("plane")
|
||||
logger.error(e)
|
||||
|
||||
# Log traceback if running in Debug
|
||||
if settings.DEBUG:
|
||||
logger.error(traceback.format_exc(e))
|
||||
# Print the traceback if in debug mode
|
||||
print(traceback.format_exc())
|
||||
|
||||
# Capture in sentry if configured
|
||||
capture_exception(e)
|
||||
|
||||
@@ -188,4 +188,17 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("created_by", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
return []
|
||||
|
||||
@@ -112,7 +112,7 @@ class OffsetPaginator:
|
||||
else (order_by[1::] if order_by.startswith("-") else order_by,)
|
||||
)
|
||||
# Set desc to true when `-` exists in the order by
|
||||
self.desc = True if order_by.startswith("-") else False
|
||||
self.desc = True if order_by and order_by.startswith("-") else False
|
||||
self.queryset = queryset
|
||||
self.max_limit = max_limit
|
||||
self.max_offset = max_offset
|
||||
@@ -187,11 +187,11 @@ class OffsetPaginator:
|
||||
|
||||
class GroupedOffsetPaginator(OffsetPaginator):
|
||||
|
||||
# Field mappers
|
||||
# Field mappers - list m2m fields here
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -205,8 +205,12 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
# Set the group by fields
|
||||
self.group_by_fields = group_by_fields
|
||||
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=50, cursor=None):
|
||||
@@ -224,8 +228,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
# Check if the offset is greater than the max offset
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
|
||||
# Check if the offset is less than 0
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
@@ -269,6 +276,8 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
@@ -305,7 +314,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
)
|
||||
|
||||
def __get_total_queryset(self):
|
||||
# Get total queryset
|
||||
# Get total items for each group
|
||||
return (
|
||||
self.queryset.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
@@ -328,7 +337,6 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
)
|
||||
+ (1 if group.get("count") == 0 else group.get("count"))
|
||||
)
|
||||
|
||||
return total_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
@@ -353,7 +361,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
# Grouping for m2m values
|
||||
total_group_dict = self.__get_total_dict()
|
||||
|
||||
# Preparing a dict to keep track of group IDs associated with each label ID
|
||||
# Preparing a dict to keep track of group IDs associated with each entity ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
# Preparing a dict to group result by group ID
|
||||
grouped_by_field_name = defaultdict(list)
|
||||
@@ -390,19 +398,12 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
# Grouping for single values
|
||||
# Grouping for values that are not m2m
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
(
|
||||
print(result["created_at"].date(), result["priority"])
|
||||
if str(result[self.group_by_field_name])
|
||||
== "c88dfd3b-e97e-4948-851b-a5fe1e36ffd0"
|
||||
else None
|
||||
)
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
if group_value in processed_results:
|
||||
processed_results[str(group_value)]["results"].append(result)
|
||||
|
||||
return processed_results
|
||||
|
||||
def process_results(self, results):
|
||||
@@ -418,10 +419,11 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
|
||||
|
||||
class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
# Field mappers this are the fields that are m2m
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -435,11 +437,18 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# Initiate the parent class for all the parameters
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
# Set the group by field name
|
||||
self.group_by_field_name = group_by_field_name
|
||||
self.group_by_fields = group_by_fields
|
||||
|
||||
# Set the sub group by field name
|
||||
self.sub_group_by_field_name = sub_group_by_field_name
|
||||
self.sub_group_by_fields = sub_group_by_fields
|
||||
|
||||
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=30, cursor=None):
|
||||
@@ -448,13 +457,19 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
# get the minimum value
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
|
||||
# the current page
|
||||
page = cursor.offset
|
||||
|
||||
# the offset
|
||||
offset = cursor.offset * cursor.value
|
||||
|
||||
# the stop
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
@@ -503,6 +518,8 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
|
||||
# Add previous cursors
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
@@ -586,19 +603,24 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
subgroup = str(item[self.sub_group_by_field_name])
|
||||
count = item["count"]
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
if group not in total_sub_group_dict:
|
||||
total_sub_group_dict[str(group)] = {}
|
||||
|
||||
# Create a dictionary of sub group
|
||||
if subgroup not in total_sub_group_dict[group]:
|
||||
total_sub_group_dict[str(group)][str(subgroup)] = {}
|
||||
|
||||
# Create a nested dictionary of group and sub group
|
||||
total_sub_group_dict[group][subgroup] = count
|
||||
|
||||
return total_group_dict, total_sub_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
# Create a field dictionary
|
||||
total_group_dict, total_sub_group_dict = self.__get_total_dict()
|
||||
|
||||
# Create a dictionary of group and sub group
|
||||
return {
|
||||
str(group): {
|
||||
"results": {
|
||||
@@ -628,7 +650,6 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
|
||||
# Use the same calculation for the sub group
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
@@ -642,6 +663,9 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
# Get the sub group value
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
# Check if the group value is in the processed results
|
||||
result_id = result["id"]
|
||||
|
||||
if (
|
||||
group_value in processed_results
|
||||
and sub_group_value
|
||||
@@ -654,12 +678,14 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
[] if "None" in group_ids else group_ids
|
||||
)
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
sub_group_ids = list(result_group_mapping[str(result_id)])
|
||||
# for multi groups
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
|
||||
[] if "None" in sub_group_ids else sub_group_ids
|
||||
sub_group_ids = list(
|
||||
result_sub_group_mapping[str(result_id)]
|
||||
)
|
||||
|
||||
# for multi groups
|
||||
result[
|
||||
self.FIELD_MAPPER.get(self.sub_group_by_field_name)
|
||||
] = ([] if "None" in sub_group_ids else sub_group_ids)
|
||||
# If a result belongs to multiple groups, add it to each group
|
||||
processed_results[str(group_value)]["results"][
|
||||
str(sub_group_value)
|
||||
]["results"].append(result)
|
||||
@@ -684,8 +710,10 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
self.group_by_field_name in self.FIELD_MAPPER
|
||||
or self.sub_group_by_field_name in self.FIELD_MAPPER
|
||||
):
|
||||
# if the grouping is done through m2m then
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
# group it directly
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
else:
|
||||
processed_results = {}
|
||||
|
||||
@@ -147,7 +147,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
<<: *app-env
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -9,7 +9,7 @@ volumes:
|
||||
|
||||
services:
|
||||
plane-redis:
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
image: redis:7.2.4-alpine
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
+3
-2
@@ -34,10 +34,11 @@
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"turbo": "^1.13.2"
|
||||
"turbo": "^2.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.48"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export enum EAuthPageTypes {
|
||||
PUBLIC = "PUBLIC",
|
||||
NON_AUTHENTICATED = "NON_AUTHENTICATED",
|
||||
SET_PASSWORD = "SET_PASSWORD",
|
||||
ONBOARDING = "ONBOARDING",
|
||||
AUTHENTICATED = "AUTHENTICATED",
|
||||
}
|
||||
|
||||
export enum EAuthModes {
|
||||
SIGN_IN = "SIGN_IN",
|
||||
SIGN_UP = "SIGN_UP",
|
||||
}
|
||||
|
||||
export enum EAuthSteps {
|
||||
EMAIL = "EMAIL",
|
||||
PASSWORD = "PASSWORD",
|
||||
UNIQUE_CODE = "UNIQUE_CODE",
|
||||
}
|
||||
|
||||
// TODO: remove this
|
||||
export enum EErrorAlertType {
|
||||
BANNER_ALERT = "BANNER_ALERT",
|
||||
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
|
||||
INLINE_EMAIL = "INLINE_EMAIL",
|
||||
INLINE_PASSWORD = "INLINE_PASSWORD",
|
||||
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
|
||||
}
|
||||
|
||||
export enum EAuthErrorCodes {
|
||||
// Global
|
||||
INSTANCE_NOT_CONFIGURED = "5000",
|
||||
INVALID_EMAIL = "5005",
|
||||
EMAIL_REQUIRED = "5010",
|
||||
SIGNUP_DISABLED = "5015",
|
||||
MAGIC_LINK_LOGIN_DISABLED = "5016",
|
||||
PASSWORD_LOGIN_DISABLED = "5018",
|
||||
USER_ACCOUNT_DEACTIVATED = "5019",
|
||||
// Password strength
|
||||
INVALID_PASSWORD = "5020",
|
||||
SMTP_NOT_CONFIGURED = "5025",
|
||||
// Sign Up
|
||||
USER_ALREADY_EXIST = "5030",
|
||||
AUTHENTICATION_FAILED_SIGN_UP = "5035",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
|
||||
INVALID_EMAIL_SIGN_UP = "5045",
|
||||
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
|
||||
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
|
||||
// Sign In
|
||||
USER_DOES_NOT_EXIST = "5060",
|
||||
AUTHENTICATION_FAILED_SIGN_IN = "5065",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
|
||||
INVALID_EMAIL_SIGN_IN = "5075",
|
||||
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
|
||||
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
|
||||
// Both Sign in and Sign up for magic
|
||||
INVALID_MAGIC_CODE_SIGN_IN = "5090",
|
||||
INVALID_MAGIC_CODE_SIGN_UP = "5092",
|
||||
EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
|
||||
EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
|
||||
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
|
||||
// Oauth
|
||||
OAUTH_NOT_CONFIGURED = "5104",
|
||||
GOOGLE_NOT_CONFIGURED = "5105",
|
||||
GITHUB_NOT_CONFIGURED = "5110",
|
||||
GITLAB_NOT_CONFIGURED = "5111",
|
||||
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
|
||||
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
|
||||
GITLAB_OAUTH_PROVIDER_ERROR = "5121",
|
||||
// Reset Password
|
||||
INVALID_PASSWORD_TOKEN = "5125",
|
||||
EXPIRED_PASSWORD_TOKEN = "5130",
|
||||
// Change password
|
||||
INCORRECT_OLD_PASSWORD = "5135",
|
||||
MISSING_PASSWORD = "5138",
|
||||
INVALID_NEW_PASSWORD = "5140",
|
||||
// set passowrd
|
||||
PASSWORD_ALREADY_SET = "5145",
|
||||
// Admin
|
||||
ADMIN_ALREADY_EXIST = "5150",
|
||||
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
|
||||
INVALID_ADMIN_EMAIL = "5160",
|
||||
INVALID_ADMIN_PASSWORD = "5165",
|
||||
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
|
||||
ADMIN_AUTHENTICATION_FAILED = "5175",
|
||||
ADMIN_USER_ALREADY_EXIST = "5180",
|
||||
ADMIN_USER_DOES_NOT_EXIST = "5185",
|
||||
ADMIN_USER_DEACTIVATED = "5190",
|
||||
// Rate limit
|
||||
RATE_LIMIT_EXCEEDED = "5900",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const useDocumentEditor = ({
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
localProvider.on("synced", () => {
|
||||
provider.setHasIndexedDBSynced(true);
|
||||
});
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface CompleteCollaboratorProviderConfiguration {
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
@@ -24,6 +28,7 @@ export class CollaborationProvider {
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
@@ -45,7 +50,12 @@ export class CollaborationProvider {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
|
||||
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
if (!this.configuration.hasIndexedDBSynced) return;
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"next",
|
||||
"turbo",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021, // Or the ECMAScript version you are using
|
||||
@@ -16,11 +11,20 @@ module.exports = {
|
||||
rootDir: ["web/", "space/", "admin/", "packages/*/"],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
},
|
||||
rules: {
|
||||
"no-useless-escape": "off",
|
||||
"prefer-const": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-useless-catch": "warn",
|
||||
"no-case-declarations": "error",
|
||||
"no-undef": "error",
|
||||
"no-unreachable": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
@@ -28,6 +32,7 @@ module.exports = {
|
||||
"react/self-closing-comp": ["error", { component: true, html: true }],
|
||||
"react/jsx-boolean-value": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error"],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-useless-empty-export": "error",
|
||||
@@ -37,6 +42,7 @@ module.exports = {
|
||||
{
|
||||
selector: ["function", "variable"],
|
||||
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
|
||||
leadingUnderscore: "allow",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,9 @@ module.exports = {
|
||||
relative: true,
|
||||
files: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./core/**/*.{js,ts,jsx,tsx}",
|
||||
"./ce/**/*.{js,ts,jsx,tsx}",
|
||||
"./ee/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.tsx",
|
||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||
"./layouts/**/*.tsx",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type TCurrentUserAccount = {
|
||||
user: string | undefined;
|
||||
|
||||
provider_account_id: string | undefined;
|
||||
provider: "google" | "github" | string | undefined;
|
||||
provider: "google" | "github" | "gitlab" | string | undefined;
|
||||
access_token: string | undefined;
|
||||
access_token_expired_at: Date | undefined;
|
||||
refresh_token: string | undefined;
|
||||
|
||||
Vendored
+63
-53
@@ -2,32 +2,81 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||
|
||||
export interface ICycle {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
export type TCycleCompletionChartDistribution = {
|
||||
[key: string]: number | null;
|
||||
};
|
||||
|
||||
export type TCycleDistributionBase = {
|
||||
total_issues: number;
|
||||
pending_issues: number;
|
||||
completed_issues: number;
|
||||
};
|
||||
|
||||
export type TCycleEstimateDistributionBase = {
|
||||
total_estimates: number;
|
||||
pending_estimates: number;
|
||||
completed_estimates: number;
|
||||
};
|
||||
|
||||
export type TCycleAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
export type TCycleLabelsDistribution = {
|
||||
color: string | null;
|
||||
label_id: string | null;
|
||||
label_name: string | null;
|
||||
};
|
||||
|
||||
export type TCycleDistribution = {
|
||||
assignees: (TCycleAssigneesDistribution & TCycleDistributionBase)[];
|
||||
completion_chart: TCycleCompletionChartDistribution;
|
||||
labels: (TCycleLabelsDistribution & TCycleDistributionBase)[];
|
||||
};
|
||||
|
||||
export type TCycleEstimateDistribution = {
|
||||
assignees: (TCycleAssigneesDistribution & TCycleEstimateDistributionBase)[];
|
||||
completion_chart: TCycleCompletionChartDistribution;
|
||||
labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[];
|
||||
};
|
||||
|
||||
export type TProgressSnapshot = {
|
||||
total_issues: number;
|
||||
completed_issues: number;
|
||||
backlog_issues: number;
|
||||
started_issues: number;
|
||||
unstarted_issues: number;
|
||||
cancelled_issues: number;
|
||||
total_estimate_points?: number;
|
||||
completed_estimate_points?: number;
|
||||
backlog_estimate_points: number;
|
||||
started_estimate_points: number;
|
||||
unstarted_estimate_points: number;
|
||||
cancelled_estimate_points: number;
|
||||
distribution?: TCycleDistribution;
|
||||
estimate_distribution?: TCycleEstimateDistribution;
|
||||
};
|
||||
|
||||
export interface ICycle extends TProgressSnapshot {
|
||||
progress_snapshot: TProgressSnapshot | undefined;
|
||||
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
description: string;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
end_date: string | null;
|
||||
id: string;
|
||||
is_favorite?: boolean;
|
||||
name: string;
|
||||
owned_by_id: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project_id: string;
|
||||
status?: TCycleGroups;
|
||||
sort_order: number;
|
||||
start_date: string | null;
|
||||
started_issues: number;
|
||||
sub_issues?: number;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
archived_at: string | null;
|
||||
@@ -38,47 +87,6 @@ export interface ICycle {
|
||||
workspace_id: string;
|
||||
}
|
||||
|
||||
export type TProgressSnapshot = {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
completed_estimates: number | null;
|
||||
completed_issues: number;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
started_estimates: number | null;
|
||||
started_issues: number;
|
||||
total_estimates: number | null;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
};
|
||||
|
||||
export type TAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
completed_issues: number;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
display_name: string | null;
|
||||
pending_issues: number;
|
||||
total_issues: number;
|
||||
};
|
||||
|
||||
export type TCompletionChartDistribution = {
|
||||
[key: string]: number | null;
|
||||
};
|
||||
|
||||
export type TLabelsDistribution = {
|
||||
color: string | null;
|
||||
completed_issues: number;
|
||||
label_id: string | null;
|
||||
label_name: string | null;
|
||||
pending_issues: number;
|
||||
total_issues: number;
|
||||
};
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
id: string;
|
||||
issue_detail: TIssue;
|
||||
@@ -102,3 +110,5 @@ export type CycleDateCheckData = {
|
||||
end_date: string;
|
||||
cycle_id?: string;
|
||||
};
|
||||
|
||||
export type TCyclePlotType = "burndown" | "points";
|
||||
|
||||
Vendored
+10
@@ -75,3 +75,13 @@ export type TEstimateUpdateStageKeys =
|
||||
| EEstimateUpdateStages.CREATE
|
||||
| EEstimateUpdateStages.EDIT
|
||||
| EEstimateUpdateStages.SWITCH;
|
||||
|
||||
export type TEstimateTypeErrorObject = {
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
export type TEstimateTypeError =
|
||||
| Record<number, TEstimateTypeErrorObject>
|
||||
| undefined;
|
||||
|
||||
+9
-2
@@ -3,7 +3,8 @@ export type TInstanceAuthenticationMethodKeys =
|
||||
| "ENABLE_MAGIC_LINK_LOGIN"
|
||||
| "ENABLE_EMAIL_PASSWORD"
|
||||
| "IS_GOOGLE_ENABLED"
|
||||
| "IS_GITHUB_ENABLED";
|
||||
| "IS_GITHUB_ENABLED"
|
||||
| "IS_GITLAB_ENABLED";
|
||||
|
||||
export type TInstanceGoogleAuthenticationConfigurationKeys =
|
||||
| "GOOGLE_CLIENT_ID"
|
||||
@@ -13,9 +14,15 @@ export type TInstanceGithubAuthenticationConfigurationKeys =
|
||||
| "GITHUB_CLIENT_ID"
|
||||
| "GITHUB_CLIENT_SECRET";
|
||||
|
||||
export type TInstanceGitlabAuthenticationConfigurationKeys =
|
||||
| "GITLAB_HOST"
|
||||
| "GITLAB_CLIENT_ID"
|
||||
| "GITLAB_CLIENT_SECRET";
|
||||
|
||||
type TInstanceAuthenticationConfigurationKeys =
|
||||
| TInstanceGoogleAuthenticationConfigurationKeys
|
||||
| TInstanceGithubAuthenticationConfigurationKeys;
|
||||
| TInstanceGithubAuthenticationConfigurationKeys
|
||||
| TInstanceGitlabAuthenticationConfigurationKeys;
|
||||
|
||||
export type TInstanceAuthenticationKeys =
|
||||
| TInstanceAuthenticationMethodKeys
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ export interface IInstance {
|
||||
export interface IInstanceConfig {
|
||||
is_google_enabled: boolean;
|
||||
is_github_enabled: boolean;
|
||||
is_gitlab_enabled: boolean;
|
||||
is_magic_login_enabled: boolean;
|
||||
is_email_password_enabled: boolean;
|
||||
github_app_name: string | undefined;
|
||||
|
||||
+76
-37
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
TIssue,
|
||||
IIssueFilterOptions,
|
||||
ILinkDetails,
|
||||
TAssigneesDistribution,
|
||||
TCompletionChartDistribution,
|
||||
TLabelsDistribution,
|
||||
} from "@plane/types";
|
||||
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
|
||||
|
||||
export type TModuleStatus =
|
||||
| "backlog"
|
||||
@@ -15,44 +8,88 @@ export type TModuleStatus =
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export interface IModule {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
export type TModuleCompletionChartDistribution = {
|
||||
[key: string]: number | null;
|
||||
};
|
||||
|
||||
export type TModuleDistributionBase = {
|
||||
total_issues: number;
|
||||
pending_issues: number;
|
||||
completed_issues: number;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
};
|
||||
|
||||
export type TModuleEstimateDistributionBase = {
|
||||
total_estimates: number;
|
||||
pending_estimates: number;
|
||||
completed_estimates: number;
|
||||
};
|
||||
|
||||
export type TModuleAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
export type TModuleLabelsDistribution = {
|
||||
color: string | null;
|
||||
label_id: string | null;
|
||||
label_name: string | null;
|
||||
};
|
||||
|
||||
export type TModuleDistribution = {
|
||||
assignees: (TModuleAssigneesDistribution & TModuleDistributionBase)[];
|
||||
completion_chart: TModuleCompletionChartDistribution;
|
||||
labels: (TModuleLabelsDistribution & TModuleDistributionBase)[];
|
||||
};
|
||||
|
||||
export type TModuleEstimateDistribution = {
|
||||
assignees: (TModuleAssigneesDistribution & TModuleEstimateDistributionBase)[];
|
||||
completion_chart: TModuleCompletionChartDistribution;
|
||||
labels: (TModuleLabelsDistribution & TModuleEstimateDistributionBase)[];
|
||||
};
|
||||
|
||||
export interface IModule {
|
||||
total_issues: number;
|
||||
completed_issues: number;
|
||||
backlog_issues: number;
|
||||
started_issues: number;
|
||||
unstarted_issues: number;
|
||||
cancelled_issues: number;
|
||||
total_estimate_points?: number;
|
||||
completed_estimate_points?: number;
|
||||
backlog_estimate_points: number;
|
||||
started_estimate_points: number;
|
||||
unstarted_estimate_points: number;
|
||||
cancelled_estimate_points: number;
|
||||
distribution?: TModuleDistribution;
|
||||
estimate_distribution?: TModuleEstimateDistribution;
|
||||
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description_text: any;
|
||||
description_html: any;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
id: string;
|
||||
lead_id: string | null;
|
||||
link_module?: ILinkDetails[];
|
||||
member_ids: string[];
|
||||
is_favorite: boolean;
|
||||
name: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
sort_order: number;
|
||||
lead_id: string | null;
|
||||
member_ids: string[];
|
||||
link_module?: ILinkDetails[];
|
||||
sub_issues?: number;
|
||||
start_date: string | null;
|
||||
started_issues: number;
|
||||
status?: TModuleStatus;
|
||||
target_date: string | null;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
total_estimate_points?: number;
|
||||
completed_estimate_points?: number;
|
||||
updated_at: string;
|
||||
updated_by?: string;
|
||||
archived_at: string | null;
|
||||
is_favorite: boolean;
|
||||
sort_order: number;
|
||||
view_props: {
|
||||
filters: IIssueFilterOptions;
|
||||
};
|
||||
workspace_id: string;
|
||||
status?: TModuleStatus;
|
||||
archived_at: string | null;
|
||||
start_date: string | null;
|
||||
target_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
export interface ModuleIssueResponse {
|
||||
@@ -78,3 +115,5 @@ export type ModuleLink = {
|
||||
export type SelectModuleType =
|
||||
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
|
||||
export type TModulePlotType = "burndown" | "points";
|
||||
|
||||
Vendored
+3
-2
@@ -3,6 +3,7 @@ import { EPageAccess } from "./enums";
|
||||
|
||||
export type TPage = {
|
||||
access: EPageAccess | undefined;
|
||||
anchor?: string | null | undefined;
|
||||
archived_at: string | null | undefined;
|
||||
color: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
@@ -11,10 +12,10 @@ export type TPage = {
|
||||
id: string | undefined;
|
||||
is_favorite: boolean;
|
||||
is_locked: boolean;
|
||||
labels: string[] | undefined;
|
||||
label_ids: string[] | undefined;
|
||||
name: string | undefined;
|
||||
owned_by: string | undefined;
|
||||
project: string | undefined;
|
||||
project_ids: string[] | undefined;
|
||||
updated_at: Date | undefined;
|
||||
updated_by: string | undefined;
|
||||
workspace: string | undefined;
|
||||
|
||||
Vendored
+10
-7
@@ -1,6 +1,6 @@
|
||||
import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types";
|
||||
|
||||
export type TPublishEntityType = "project";
|
||||
export type TPublishEntityType = "project" | "page";
|
||||
|
||||
export type TProjectPublishLayouts =
|
||||
| "calendar"
|
||||
@@ -9,7 +9,7 @@ export type TProjectPublishLayouts =
|
||||
| "list"
|
||||
| "spreadsheet";
|
||||
|
||||
export type TPublishViewProps = {
|
||||
export type TProjectPublishViewProps = {
|
||||
calendar?: boolean;
|
||||
gantt?: boolean;
|
||||
kanban?: boolean;
|
||||
@@ -20,22 +20,25 @@ export type TPublishViewProps = {
|
||||
export type TProjectDetails = IProjectLite &
|
||||
Pick<IProject, "cover_image" | "logo_props" | "description">;
|
||||
|
||||
export type TPublishSettings = {
|
||||
type TPublishSettings = {
|
||||
anchor: string | undefined;
|
||||
is_comments_enabled: boolean;
|
||||
created_at: string | undefined;
|
||||
created_by: string | undefined;
|
||||
entity_identifier: string | undefined;
|
||||
entity_name: TPublishEntityType | undefined;
|
||||
id: string | undefined;
|
||||
inbox: unknown;
|
||||
is_comments_enabled: boolean;
|
||||
is_reactions_enabled: boolean;
|
||||
is_votes_enabled: boolean;
|
||||
project: string | undefined;
|
||||
project_details: TProjectDetails | undefined;
|
||||
is_reactions_enabled: boolean;
|
||||
updated_at: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
view_props: TViewProps | undefined;
|
||||
is_votes_enabled: boolean;
|
||||
workspace: string | undefined;
|
||||
workspace_detail: IWorkspaceLite | undefined;
|
||||
};
|
||||
|
||||
export type TProjectPublishSettings = TPublishSettings & {
|
||||
view_props: TProjectPublishViewProps | undefined;
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
TStateGroups,
|
||||
} from ".";
|
||||
|
||||
type TLoginMediums = "email" | "magic-code" | "github" | "google";
|
||||
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
|
||||
Vendored
+3
@@ -13,6 +13,9 @@ export interface IProjectView {
|
||||
is_favorite: boolean;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
isLocked: boolean | null;
|
||||
isPrivate: boolean | null;
|
||||
isPublished: boolean | null;
|
||||
name: string;
|
||||
description: string;
|
||||
filters: IIssueFilterOptions;
|
||||
|
||||
Vendored
+9
-1
@@ -112,6 +112,14 @@ export interface IWorkspaceIssueSearchResult {
|
||||
workspace__slug: string;
|
||||
}
|
||||
|
||||
export interface IWorkspacePageSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
project_ids: string[];
|
||||
project__identifiers: string[];
|
||||
workspace__slug: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceProjectSearchResult {
|
||||
id: string;
|
||||
identifier: string;
|
||||
@@ -127,7 +135,7 @@ export interface IWorkspaceSearchResults {
|
||||
cycle: IWorkspaceDefaultSearchResult[];
|
||||
module: IWorkspaceDefaultSearchResult[];
|
||||
issue_view: IWorkspaceDefaultSearchResult[];
|
||||
page: IWorkspaceDefaultSearchResult[];
|
||||
page: IWorkspacePageSearchResult[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^2.0.3",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
@@ -34,7 +34,8 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"use-font-face-observer": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.4.0",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelect
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
{buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
disableSearch,
|
||||
keyExtractor,
|
||||
options,
|
||||
handleClose,
|
||||
value,
|
||||
renderItem,
|
||||
loader,
|
||||
@@ -46,7 +47,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
options?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={keyExtractor(option)}
|
||||
value={option.data[option.value]}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
|
||||
@@ -58,6 +59,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
option.className && option.className({ active, selected })
|
||||
)
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
@@ -65,7 +67,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
<>{renderItem({ value: option.data[option.value], selected })}</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-grow truncate">{value}</span>
|
||||
<span className="flex-grow truncate">{option.value}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
+5
-3
@@ -24,8 +24,8 @@ export interface IDropdown {
|
||||
// options props
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
optionsContainerClassName?: string;
|
||||
queryArray: string[];
|
||||
sortByKey: string;
|
||||
queryArray?: string[];
|
||||
sortByKey?: string;
|
||||
firstItem?: (optionValue: string) => boolean;
|
||||
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
|
||||
loader?: React.ReactNode;
|
||||
@@ -52,7 +52,7 @@ export interface ISingleSelectDropdown extends IDropdown {
|
||||
|
||||
export interface IDropdownButton {
|
||||
isOpen: boolean;
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
@@ -79,6 +79,8 @@ export interface IDropdownOptions {
|
||||
inputContainerClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
|
||||
handleClose?: () => void;
|
||||
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
|
||||
@@ -90,10 +90,12 @@ export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
const filteredOptions = queryArray
|
||||
? (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
})
|
||||
: options;
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
|
||||
@@ -90,12 +90,14 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
const filteredOptions = queryArray
|
||||
? (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
})
|
||||
: options;
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
if (disableSorting || !sortByKey) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
@@ -157,6 +159,7 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const emojiCodeToUnicode = (emoji: string) => {
|
||||
if (!emoji) return "";
|
||||
|
||||
// convert emoji code to unicode
|
||||
const uniCodeEmoji = emoji
|
||||
.split("-")
|
||||
.map((emoji) => parseInt(emoji, 10).toString(16))
|
||||
.join("-");
|
||||
|
||||
return uniCodeEmoji;
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// icons
|
||||
import { Search } from "lucide-react";
|
||||
import { MATERIAL_ICONS_LIST } from "./icons";
|
||||
import { InfoIcon } from "../icons";
|
||||
// components
|
||||
import { Input } from "../form-fields";
|
||||
// hooks
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
|
||||
// icons
|
||||
import { MATERIAL_ICONS_LIST } from "./icons";
|
||||
import { InfoIcon } from "../icons";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
const { defaultColor, onChange } = props;
|
||||
@@ -28,6 +30,15 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
|
||||
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-2 py-[15px] w-full ">
|
||||
@@ -118,12 +129,16 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ color: activeColor }}
|
||||
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
|
||||
>
|
||||
{icon.name}
|
||||
</span>
|
||||
{isMaterialSymbolsFontLoaded ? (
|
||||
<span
|
||||
style={{ color: activeColor }}
|
||||
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
|
||||
>
|
||||
{icon.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-5 rounded animate-pulse bg-custom-background-80" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./emoji-icon-picker-new";
|
||||
export * from "./emoji-icon-picker";
|
||||
export * from "./emoji-icon-helper";
|
||||
export * from "./icons";
|
||||
export * from "./logo";
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { FC } from "react";
|
||||
import { Emoji } from "emoji-picker-react";
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// icons
|
||||
import { LUCIDE_ICONS_LIST } from "./icons";
|
||||
// helpers
|
||||
import { emojiCodeToUnicode } from "./helpers";
|
||||
|
||||
type TLogoProps = {
|
||||
in_use: "emoji" | "icon";
|
||||
emoji?: {
|
||||
value?: string;
|
||||
url?: string;
|
||||
};
|
||||
icon?: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
logo: TLogoProps;
|
||||
size?: number;
|
||||
type?: "lucide" | "material";
|
||||
};
|
||||
|
||||
export const Logo: FC<Props> = (props) => {
|
||||
const { logo, size = 16, type = "material" } = props;
|
||||
|
||||
// destructuring the logo object
|
||||
const { in_use, emoji, icon } = logo;
|
||||
|
||||
// derived values
|
||||
const value = in_use === "emoji" ? emoji?.value : icon?.name;
|
||||
const color = icon?.color;
|
||||
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
// if no value, return empty fragment
|
||||
if (!value) return <></>;
|
||||
|
||||
if (!isMaterialSymbolsFontLoaded) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className="rounded animate-pulse bg-custom-background-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// emoji
|
||||
if (in_use === "emoji") {
|
||||
return <Emoji unified={emojiCodeToUnicode(value)} size={size} />;
|
||||
}
|
||||
|
||||
// icon
|
||||
if (in_use === "icon") {
|
||||
return (
|
||||
<>
|
||||
{type === "lucide" ? (
|
||||
<>
|
||||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// if no value, return empty fragment
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./use-page-title";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface IUseHeadParams {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const useHead = ({ title }: IUseHeadParams) => {
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = title ?? "Plane | Simple, extensible, open-source project management tool.";
|
||||
}
|
||||
}, [title]);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const GitlabIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_282_232)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 0C4.475 0 0 4.475 0 10C0 14.425 2.8625 18.1625 6.8375 19.4875C7.3375 19.575 7.525 19.275 7.525 19.0125C7.525 18.775 7.5125 17.9875 7.5125 17.15C5 17.6125 4.35 16.5375 4.15 15.975C4.0375 15.6875 3.55 14.8 3.125 14.5625C2.775 14.375 2.275 13.9125 3.1125 13.9C3.9 13.8875 4.4625 14.625 4.65 14.925C5.55 16.4375 6.9875 16.0125 7.5625 15.75C7.65 15.1 7.9125 14.6625 8.2 14.4125C5.975 14.1625 3.65 13.3 3.65 9.475C3.65 8.3875 4.0375 7.4875 4.675 6.7875C4.575 6.5375 4.225 5.5125 4.775 4.1375C4.775 4.1375 5.6125 3.875 7.525 5.1625C8.325 4.9375 9.175 4.825 10.025 4.825C10.875 4.825 11.725 4.9375 12.525 5.1625C14.4375 3.8625 15.275 4.1375 15.275 4.1375C15.825 5.5125 15.475 6.5375 15.375 6.7875C16.0125 7.4875 16.4 8.375 16.4 9.475C16.4 13.3125 14.0625 14.1625 11.8375 14.4125C12.2 14.725 12.5125 15.325 12.5125 16.2625C12.5125 17.6 12.5 18.675 12.5 19.0125C12.5 19.275 12.6875 19.5875 13.1875 19.4875C15.1726 18.8173 16.8976 17.5414 18.1197 15.8395C19.3418 14.1375 19.9994 12.0952 20 10C20 4.475 15.525 0 10 0Z"
|
||||
fill={color ? color : "rgb(var(--color-text-200))"}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_282_232">
|
||||
<rect width={width} height={height} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -12,6 +12,7 @@ export * from "./dice-icon";
|
||||
export * from "./discord-icon";
|
||||
export * from "./full-screen-panel-icon";
|
||||
export * from "./github-icon";
|
||||
export * from "./gitlab-icon";
|
||||
export * from "./layer-stack";
|
||||
export * from "./layers-icon";
|
||||
export * from "./photo-filter-icon";
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./dropdowns";
|
||||
export * from "./dropdown";
|
||||
export * from "./form-fields";
|
||||
export * from "./icons";
|
||||
export * from "./modals";
|
||||
export * from "./progress";
|
||||
export * from "./spinners";
|
||||
export * from "./tooltip";
|
||||
@@ -17,3 +18,4 @@ export * from "./drag-handle";
|
||||
export * from "./typography";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./sortable";
|
||||
export * from "./hooks";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
|
||||
// ui
|
||||
import { Button, TButtonVariant } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { Button, TButtonVariant } from "../button";
|
||||
import { ModalCore } from "./modal-core";
|
||||
// constants
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export type TModalVariant = "danger" | "primary";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./alert-modal";
|
||||
export * from "./constants";
|
||||
export * from "./modal-core";
|
||||
@@ -1,19 +1,9 @@
|
||||
import { Fragment } from "react";
|
||||
import React, { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { EModalPosition, EModalWidth } from "./constants";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
}
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
// types
|
||||
import { TPublishSettings } from "@plane/types";
|
||||
import { TProjectPublishSettings } from "@plane/types";
|
||||
// services
|
||||
import PublishService from "@/services/publish.service";
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function IssuesPage(props: Props) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
const { board, peekId } = searchParams;
|
||||
|
||||
let response: TPublishSettings | undefined = undefined;
|
||||
let response: TProjectPublishSettings | undefined = undefined;
|
||||
try {
|
||||
response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import useSWR from "swr";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { IssuesNavbarRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store";
|
||||
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
|
||||
// assets
|
||||
import planeLogo from "@/public/plane-logo.svg";
|
||||
|
||||
@@ -25,8 +25,25 @@ const IssuesLayout = observer((props: Props) => {
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null);
|
||||
useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings) return <LogoSpinner />;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -85,7 +85,7 @@ export const AuthRoot: FC = observer(() => {
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
|
||||
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
|
||||
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled)) || false;
|
||||
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
|
||||
// submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
+4
-3
@@ -66,9 +66,10 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
autoFocus
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
|
||||
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={() => setEmail("")} />
|
||||
</div>
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setEmail("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user