@@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
- workspace, you will need to login again.
+ workspace.
-
+
Create workspace
-
+
Close
diff --git a/admin/core/components/workspace/index.ts b/admin/core/components/workspace/index.ts
new file mode 100644
index 0000000000..24950c4f20
--- /dev/null
+++ b/admin/core/components/workspace/index.ts
@@ -0,0 +1 @@
+export * from "./list-item";
diff --git a/admin/core/components/workspace/list-item.tsx b/admin/core/components/workspace/list-item.tsx
new file mode 100644
index 0000000000..ae693eb728
--- /dev/null
+++ b/admin/core/components/workspace/list-item.tsx
@@ -0,0 +1,81 @@
+import { observer } from "mobx-react";
+import { ExternalLink } from "lucide-react";
+// plane internal packages
+import { WEB_BASE_URL } from "@plane/constants";
+import { Tooltip } from "@plane/ui";
+import { getFileURL } from "@plane/utils";
+// hooks
+import { useWorkspace } from "@/hooks/store";
+
+type TWorkspaceListItemProps = {
+ workspaceId: string;
+};
+
+export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => {
+ // store hooks
+ const { getWorkspaceById } = useWorkspace();
+ // derived values
+ const workspace = getWorkspaceById(workspaceId);
+
+ if (!workspace) return null;
+ return (
+
+
+
+ {workspace?.logo_url && workspace.logo_url !== "" ? (
+
+ ) : (
+ (workspace?.name?.[0] ?? "...")
+ )}
+
+
+
+
{workspace.name} /
+
+ [{workspace.slug}]
+
+
+ {workspace.owner.email && (
+
+
Owned by:
+ {workspace.owner.email}
+
+ )}
+
+ {workspace.total_projects !== null && (
+
+ Total projects:
+ {workspace.total_projects}
+
+ )}
+ {workspace.total_members !== null && (
+ <>
+ •
+
+ Total members:
+ {workspace.total_members}
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+});
diff --git a/admin/core/constants/seo.ts b/admin/core/constants/seo.ts
deleted file mode 100644
index aafd5f7a3e..0000000000
--- a/admin/core/constants/seo.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool.";
-export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool.";
-export const SITE_DESCRIPTION =
- "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
-export const SITE_KEYWORDS =
- "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
-export const SITE_URL = "https://app.plane.so/";
-export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool.";
diff --git a/admin/core/hooks/store/index.ts b/admin/core/hooks/store/index.ts
index 7447064da7..ed1781299f 100644
--- a/admin/core/hooks/store/index.ts
+++ b/admin/core/hooks/store/index.ts
@@ -1,3 +1,4 @@
export * from "./use-theme";
export * from "./use-instance";
export * from "./use-user";
+export * from "./use-workspace";
diff --git a/admin/core/hooks/store/use-workspace.tsx b/admin/core/hooks/store/use-workspace.tsx
new file mode 100644
index 0000000000..e3bde92d53
--- /dev/null
+++ b/admin/core/hooks/store/use-workspace.tsx
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-provider";
+import { IWorkspaceStore } from "@/store/workspace.store";
+
+export const useWorkspace = (): IWorkspaceStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider");
+ return context.workspace;
+};
diff --git a/admin/core/lib/auth-helpers.tsx b/admin/core/lib/auth-helpers.tsx
new file mode 100644
index 0000000000..582b56e298
--- /dev/null
+++ b/admin/core/lib/auth-helpers.tsx
@@ -0,0 +1,164 @@
+import { ReactNode } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { KeyRound, Mails } from "lucide-react";
+// plane packages
+import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
+import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
+import { resolveGeneralTheme } from "@plane/utils";
+// components
+import {
+ EmailCodesConfiguration,
+ GithubConfiguration,
+ GitlabConfiguration,
+ GoogleConfiguration,
+ PasswordLoginConfiguration,
+} from "@/components/authentication";
+// 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";
+
+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",
+}
+
+const errorCodeMessages: {
+ [key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
+} = {
+ // admin
+ [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
+ title: `Admin already exists`,
+ message: () => `Admin already exists. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
+ title: `Email, password and first name required`,
+ message: () => `Email, password and first name required. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
+ title: `Invalid admin email`,
+ message: () => `Invalid admin email. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
+ title: `Invalid admin password`,
+ message: () => `Invalid admin password. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
+ title: `Email and password required`,
+ message: () => `Email and password required. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
+ title: `Authentication failed`,
+ message: () => `Authentication failed. Please try again.`,
+ },
+ [EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
+ title: `Admin user already exists`,
+ message: () => (
+
+ Admin user already exists.
+
+ Sign In
+
+ now.
+
+ ),
+ },
+ [EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
+ title: `Admin user does not exist`,
+ message: () => (
+
+ Admin user does not exist.
+
+ Sign In
+
+ now.
+
+ ),
+ },
+ [EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
+ title: `User account deactivated`,
+ message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
+ },
+};
+
+export const authErrorHandler = (
+ errorCode: EAdminAuthErrorCodes,
+ email?: string | undefined
+): TAuthErrorInfo | undefined => {
+ const bannerAlertErrorCodes = [
+ EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
+ EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+ EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL,
+ EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD,
+ EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
+ EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
+ EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
+ EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
+ EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED,
+ ];
+
+ if (bannerAlertErrorCodes.includes(errorCode))
+ return {
+ type: EErrorAlertType.BANNER_ALERT,
+ code: errorCode,
+ title: errorCodeMessages[errorCode]?.title || "Error",
+ message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
+ };
+
+ return undefined;
+};
+
+export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
+ disabled,
+ updateConfig,
+ resolvedTheme,
+}) => [
+ {
+ key: "unique-codes",
+ name: "Unique codes",
+ description:
+ "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
+ icon:
,
+ config:
,
+ },
+ {
+ key: "passwords-login",
+ name: "Passwords",
+ description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
+ icon:
,
+ config:
,
+ },
+ {
+ key: "google",
+ name: "Google",
+ description: "Allow members to log in or sign up for Plane with their Google accounts.",
+ icon:
,
+ config:
,
+ },
+ {
+ key: "github",
+ name: "GitHub",
+ description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
+ icon: (
+
+ ),
+ config:
,
+ },
+ {
+ key: "gitlab",
+ name: "GitLab",
+ description: "Allow members to log in or sign up to plane with their GitLab accounts.",
+ icon:
,
+ config:
,
+ },
+];
diff --git a/admin/core/services/auth.service.ts b/admin/core/services/auth.service.ts
index 2cea01beec..a47fd83962 100644
--- a/admin/core/services/auth.service.ts
+++ b/admin/core/services/auth.service.ts
@@ -1,5 +1,4 @@
-// helpers
-import { API_BASE_URL } from "@/helpers/common.helper";
+import { API_BASE_URL } from "@plane/constants";
// services
import { APIService } from "@/services/api.service";
diff --git a/admin/core/services/instance.service.ts b/admin/core/services/instance.service.ts
index feb94ceea4..510a780d79 100644
--- a/admin/core/services/instance.service.ts
+++ b/admin/core/services/instance.service.ts
@@ -1,4 +1,5 @@
-// types
+// plane internal packages
+import { API_BASE_URL } from "@plane/constants";
import type {
IFormattedInstanceConfiguration,
IInstance,
@@ -7,7 +8,6 @@ import type {
IInstanceInfo,
} from "@plane/types";
// helpers
-import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
diff --git a/admin/core/services/user.service.ts b/admin/core/services/user.service.ts
index 42eb6eb224..74ef2a81bf 100644
--- a/admin/core/services/user.service.ts
+++ b/admin/core/services/user.service.ts
@@ -1,7 +1,6 @@
-// types
+// plane internal packages
+import { API_BASE_URL } from "@plane/constants";
import type { IUser } from "@plane/types";
-// helpers
-import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts
new file mode 100644
index 0000000000..787ad42698
--- /dev/null
+++ b/admin/core/services/workspace.service.ts
@@ -0,0 +1,52 @@
+// plane internal packages
+import { API_BASE_URL } from "@plane/constants";
+import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
+// services
+import { APIService } from "@/services/api.service";
+
+export class WorkspaceService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ /**
+ * @description Fetches all workspaces
+ * @returns Promise
+ */
+ async getWorkspaces(nextPageCursor?: string): Promise {
+ return this.get("/api/instances/workspaces/", {
+ cursor: nextPageCursor,
+ })
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ /**
+ * @description Checks if a slug is available
+ * @param slug - string
+ * @returns Promise
+ */
+ async workspaceSlugCheck(slug: string): Promise {
+ const params = new URLSearchParams({ slug });
+ return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ /**
+ * @description Creates a new workspace
+ * @param data - IWorkspace
+ * @returns Promise
+ */
+ async createWorkspace(data: IWorkspace): Promise {
+ return this.post("/api/instances/workspaces/", data)
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts
index 01ab552846..0daadb1fcd 100644
--- a/admin/core/store/instance.store.ts
+++ b/admin/core/store/instance.store.ts
@@ -1,5 +1,7 @@
import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
+// plane internal packages
+import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {
IInstance,
IInstanceAdmin,
@@ -8,8 +10,6 @@ import {
IInstanceInfo,
IInstanceConfig,
} from "@plane/types";
-// helpers
-import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
diff --git a/admin/core/store/root.store.ts b/admin/core/store/root.store.ts
index 4b25bcc686..8c53061ab9 100644
--- a/admin/core/store/root.store.ts
+++ b/admin/core/store/root.store.ts
@@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react";
import { IInstanceStore, InstanceStore } from "./instance.store";
import { IThemeStore, ThemeStore } from "./theme.store";
import { IUserStore, UserStore } from "./user.store";
+import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
enableStaticRendering(typeof window === "undefined");
@@ -10,17 +11,20 @@ export abstract class CoreRootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
+ workspace: IWorkspaceStore;
constructor() {
this.theme = new ThemeStore(this);
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
+ this.workspace = new WorkspaceStore(this);
}
hydrate(initialData: any) {
this.theme.hydrate(initialData.theme);
this.instance.hydrate(initialData.instance);
this.user.hydrate(initialData.user);
+ this.workspace.hydrate(initialData.workspace);
}
resetOnSignOut() {
@@ -28,5 +32,6 @@ export abstract class CoreRootStore {
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
this.theme = new ThemeStore(this);
+ this.workspace = new WorkspaceStore(this);
}
}
diff --git a/admin/core/store/user.store.ts b/admin/core/store/user.store.ts
index df17c9b004..7f56c0523e 100644
--- a/admin/core/store/user.store.ts
+++ b/admin/core/store/user.store.ts
@@ -1,7 +1,7 @@
import { action, observable, runInAction, makeObservable } from "mobx";
+// plane internal packages
+import { EUserStatus, TUserStatus } from "@plane/constants";
import { IUser } from "@plane/types";
-// helpers
-import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
diff --git a/admin/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts
new file mode 100644
index 0000000000..f892e14f0c
--- /dev/null
+++ b/admin/core/store/workspace.store.ts
@@ -0,0 +1,150 @@
+import set from "lodash/set";
+import { action, observable, runInAction, makeObservable, computed } from "mobx";
+import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
+// services
+import { WorkspaceService } from "@/services/workspace.service";
+// root store
+import { CoreRootStore } from "@/store/root.store";
+
+export interface IWorkspaceStore {
+ // observables
+ loader: TLoader;
+ workspaces: Record;
+ paginationInfo: TPaginationInfo | undefined;
+ // computed
+ workspaceIds: string[];
+ // helper actions
+ hydrate: (data: Record) => void;
+ getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
+ // fetch actions
+ fetchWorkspaces: () => Promise;
+ fetchNextWorkspaces: () => Promise;
+ // curd actions
+ createWorkspace: (data: IWorkspace) => Promise;
+}
+
+export class WorkspaceStore implements IWorkspaceStore {
+ // observables
+ loader: TLoader = "init-loader";
+ workspaces: Record = {};
+ paginationInfo: TPaginationInfo | undefined = undefined;
+ // services
+ workspaceService;
+
+ constructor(private store: CoreRootStore) {
+ makeObservable(this, {
+ // observables
+ loader: observable,
+ workspaces: observable,
+ paginationInfo: observable,
+ // computed
+ workspaceIds: computed,
+ // helper actions
+ hydrate: action,
+ getWorkspaceById: action,
+ // fetch actions
+ fetchWorkspaces: action,
+ fetchNextWorkspaces: action,
+ // curd actions
+ createWorkspace: action,
+ });
+ this.workspaceService = new WorkspaceService();
+ }
+
+ // computed
+ get workspaceIds() {
+ return Object.keys(this.workspaces);
+ }
+
+ // helper actions
+ /**
+ * @description Hydrates the workspaces
+ * @param data - Record
+ */
+ hydrate = (data: Record) => {
+ if (data) this.workspaces = data;
+ };
+
+ /**
+ * @description Gets a workspace by id
+ * @param workspaceId - string
+ * @returns IWorkspace | undefined
+ */
+ getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId];
+
+ // fetch actions
+ /**
+ * @description Fetches all workspaces
+ * @returns Promise<>
+ */
+ fetchWorkspaces = async (): Promise => {
+ try {
+ if (this.workspaceIds.length > 0) {
+ this.loader = "mutation";
+ } else {
+ this.loader = "init-loader";
+ }
+ const paginatedWorkspaceData = await this.workspaceService.getWorkspaces();
+ runInAction(() => {
+ const { results, ...paginationInfo } = paginatedWorkspaceData;
+ results.forEach((workspace: IWorkspace) => {
+ set(this.workspaces, [workspace.id], workspace);
+ });
+ set(this, "paginationInfo", paginationInfo);
+ });
+ return paginatedWorkspaceData.results;
+ } catch (error) {
+ console.error("Error fetching workspaces", error);
+ throw error;
+ } finally {
+ this.loader = "loaded";
+ }
+ };
+
+ /**
+ * @description Fetches the next page of workspaces
+ * @returns Promise
+ */
+ fetchNextWorkspaces = async (): Promise => {
+ if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
+ try {
+ this.loader = "pagination";
+ const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor);
+ runInAction(() => {
+ const { results, ...paginationInfo } = paginatedWorkspaceData;
+ results.forEach((workspace: IWorkspace) => {
+ set(this.workspaces, [workspace.id], workspace);
+ });
+ set(this, "paginationInfo", paginationInfo);
+ });
+ return paginatedWorkspaceData.results;
+ } catch (error) {
+ console.error("Error fetching next workspaces", error);
+ throw error;
+ } finally {
+ this.loader = "loaded";
+ }
+ };
+
+ // curd actions
+ /**
+ * @description Creates a new workspace
+ * @param data - IWorkspace
+ * @returns Promise
+ */
+ createWorkspace = async (data: IWorkspace): Promise => {
+ try {
+ this.loader = "mutation";
+ const workspace = await this.workspaceService.createWorkspace(data);
+ runInAction(() => {
+ set(this.workspaces, [workspace.id], workspace);
+ });
+ return workspace;
+ } catch (error) {
+ console.error("Error creating workspace", error);
+ throw error;
+ } finally {
+ this.loader = "loaded";
+ }
+ };
+}
diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx
deleted file mode 100644
index 627ff182ca..0000000000
--- a/admin/helpers/authentication.helper.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { ReactNode } from "react";
-import Image from "next/image";
-import Link from "next/link";
-import { KeyRound, Mails } from "lucide-react";
-// types
-import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
-// components
-import {
- EmailCodesConfiguration,
- GithubConfiguration,
- GitlabConfiguration,
- GoogleConfiguration,
- PasswordLoginConfiguration,
-} from "@/components/authentication";
-// helpers
-import { SUPPORT_EMAIL, resolveGeneralTheme } from "@/helpers/common.helper";
-// 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";
-
-export enum EPageTypes {
- 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",
-}
-
-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 EAuthenticationErrorCodes {
- // 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",
-}
-
-export type TAuthErrorInfo = {
- type: EErrorAlertType;
- code: EAuthenticationErrorCodes;
- title: string;
- message: ReactNode;
-};
-
-const errorCodeMessages: {
- [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
-} = {
- // admin
- [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
- title: `Admin already exists`,
- message: () => `Admin already exists. Please try again.`,
- },
- [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
- title: `Email, password and first name required`,
- message: () => `Email, password and first name required. Please try again.`,
- },
- [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
- title: `Invalid admin email`,
- message: () => `Invalid admin email. Please try again.`,
- },
- [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
- title: `Invalid admin password`,
- message: () => `Invalid admin password. Please try again.`,
- },
- [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
- title: `Email and password required`,
- message: () => `Email and password required. Please try again.`,
- },
- [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
- title: `Authentication failed`,
- message: () => `Authentication failed. Please try again.`,
- },
- [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
- title: `Admin user already exists`,
- message: () => (
-
- Admin user already exists.
-
- Sign In
-
- now.
-
- ),
- },
- [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
- title: `Admin user does not exist`,
- message: () => (
-
- Admin user does not exist.
-
- Sign In
-
- now.
-
- ),
- },
- [EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: {
- title: `User account deactivated`,
- message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
- },
-};
-
-export const authErrorHandler = (
- errorCode: EAuthenticationErrorCodes,
- email?: string | undefined
-): TAuthErrorInfo | undefined => {
- const bannerAlertErrorCodes = [
- EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
- EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
- EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
- EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
- EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
- EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
- EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
- EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
- EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED,
- ];
-
- if (bannerAlertErrorCodes.includes(errorCode))
- return {
- type: EErrorAlertType.BANNER_ALERT,
- code: errorCode,
- title: errorCodeMessages[errorCode]?.title || "Error",
- message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
- };
-
- return undefined;
-};
-
-export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
- disabled,
- updateConfig,
- resolvedTheme,
-}) => [
- {
- key: "unique-codes",
- name: "Unique codes",
- description:
- "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
- icon: ,
- config: ,
- },
- {
- key: "passwords-login",
- name: "Passwords",
- description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
- icon: ,
- config: ,
- },
- {
- key: "google",
- name: "Google",
- description: "Allow members to log in or sign up for Plane with their Google accounts.",
- icon: ,
- config: ,
- },
- {
- key: "github",
- name: "GitHub",
- description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
- icon: (
-
- ),
- config: ,
- },
- {
- key: "gitlab",
- name: "GitLab",
- description: "Allow members to log in or sign up to plane with their GitLab accounts.",
- icon: ,
- config: ,
- },
- ];
diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts
deleted file mode 100644
index e282e57925..0000000000
--- a/admin/helpers/common.helper.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
-
-export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
-
-export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
-export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
-
-export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
-
-export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
-
-export const ASSET_PREFIX = ADMIN_BASE_PATH;
-
-export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
-
-export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
- resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
diff --git a/admin/helpers/file.helper.ts b/admin/helpers/file.helper.ts
deleted file mode 100644
index 6e1f546360..0000000000
--- a/admin/helpers/file.helper.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// helpers
-import { API_BASE_URL } from "@/helpers/common.helper";
-
-/**
- * @description combine the file path with the base URL
- * @param {string} path
- * @returns {string} final URL with the base URL
- */
-export const getFileURL = (path: string): string | undefined => {
- if (!path) return undefined;
- const isValidURL = path.startsWith("http");
- if (isValidURL) return path;
- return `${API_BASE_URL}${path}`;
-};
diff --git a/admin/helpers/index.ts b/admin/helpers/index.ts
deleted file mode 100644
index ae6aab829c..0000000000
--- a/admin/helpers/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./instance.helper";
-export * from "./user.helper";
diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts
deleted file mode 100644
index dfe9a5c65f..0000000000
--- a/admin/helpers/password.helper.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import zxcvbn from "zxcvbn";
-
-export enum E_PASSWORD_STRENGTH {
- EMPTY = "empty",
- LENGTH_NOT_VALID = "length_not_valid",
- STRENGTH_NOT_VALID = "strength_not_valid",
- STRENGTH_VALID = "strength_valid",
-}
-
-const PASSWORD_MIN_LENGTH = 8;
-// const PASSWORD_NUMBER_REGEX = /\d/;
-// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
-// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
-
-export const PASSWORD_CRITERIA = [
- {
- key: "min_8_char",
- label: "Min 8 characters",
- isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
- },
- // {
- // key: "min_1_upper_case",
- // label: "Min 1 upper-case letter",
- // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
- // },
- // {
- // key: "min_1_number",
- // label: "Min 1 number",
- // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
- // },
- // {
- // key: "min_1_special_char",
- // label: "Min 1 special character",
- // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
- // },
-];
-
-export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
- let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
-
- if (!password || password === "" || password.length <= 0) {
- return passwordStrength;
- }
-
- if (password.length >= PASSWORD_MIN_LENGTH) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
- } else {
- passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
- return passwordStrength;
- }
-
- const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
- (criterion) => criterion
- );
- const passwordStrengthScore = zxcvbn(password).score;
-
- if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
- return passwordStrength;
- }
-
- if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
- }
-
- return passwordStrength;
-};
diff --git a/admin/helpers/string.helper.ts b/admin/helpers/string.helper.ts
deleted file mode 100644
index a48508118e..0000000000
--- a/admin/helpers/string.helper.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @description
- * This function test whether a URL is valid or not.
- *
- * It accepts URLs with or without the protocol.
- * @param {string} url
- * @returns {boolean}
- * @example
- * checkURLValidity("https://example.com") => true
- * checkURLValidity("example.com") => true
- * checkURLValidity("example") => false
- */
-export const checkURLValidity = (url: string): boolean => {
- if (!url) return false;
-
- // regex to support complex query parameters and fragments
- const urlPattern =
- /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
-
- return urlPattern.test(url);
-};
diff --git a/admin/package.json b/admin/package.json
index 41400da227..e2fe4cf331 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "admin",
- "version": "0.23.1",
+ "version": "0.24.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -14,9 +14,10 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
- "@plane/helpers": "*",
+ "@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
+ "@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -26,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
- "next": "^14.2.12",
+ "next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
index 1748435814..f9bb7cf10b 100644
--- a/admin/tsconfig.json
+++ b/admin/tsconfig.json
@@ -5,7 +5,6 @@
"baseUrl": ".",
"paths": {
"@/*": ["core/*"],
- "@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
}
diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api
index 97a2b2d410..b0fa447885 100644
--- a/apiserver/Dockerfile.api
+++ b/apiserver/Dockerfile.api
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
-ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
+ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
WORKDIR /code
diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev
index c81966de4f..3ec8c6340a 100644
--- a/apiserver/Dockerfile.dev
+++ b/apiserver/Dockerfile.dev
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
-ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
+ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
RUN apk --no-cache add \
"bash~=5.2" \
diff --git a/apiserver/package.json b/apiserver/package.json
index f26382c893..6d350d83c9 100644
--- a/apiserver/package.json
+++ b/apiserver/package.json
@@ -1,4 +1,4 @@
{
"name": "plane-api",
- "version": "0.23.1"
+ "version": "0.24.1"
}
diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py
index f4f06c324a..d394dc9bd6 100644
--- a/apiserver/plane/api/serializers/cycle.py
+++ b/apiserver/plane/api/serializers/cycle.py
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
-
+from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
@@ -24,6 +24,18 @@ class CycleSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
+
+ if (
+ data.get("start_date", None) is not None
+ and data.get("end_date", None) is not None
+ ):
+ project_id = self.initial_data.get("project_id") or self.instance.project_id
+ data["start_date"] = convert_to_utc(
+ str(data.get("start_date").date()), project_id, is_start_date=True
+ )
+ data["end_date"] = convert_to_utc(
+ str(data.get("end_date", None).date()), project_id
+ )
return data
class Meta:
diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index 72918b2683..3293b79dd9 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -237,17 +237,37 @@ class IssueSerializer(BaseSerializer):
from .user import UserLiteSerializer
data["assignees"] = UserLiteSerializer(
- instance.assignees.all(), many=True
+ User.objects.filter(
+ pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
+ "assignee_id", flat=True
+ )
+ ),
+ many=True,
).data
else:
data["assignees"] = [
- str(assignee.id) for assignee in instance.assignees.all()
+ str(assignee)
+ for assignee in IssueAssignee.objects.filter(
+ issue=instance
+ ).values_list("assignee_id", flat=True)
]
if "labels" in self.fields:
if "labels" in self.expand:
- data["labels"] = LabelSerializer(instance.labels.all(), many=True).data
+ data["labels"] = LabelSerializer(
+ Label.objects.filter(
+ pk__in=IssueLabel.objects.filter(issue=instance).values_list(
+ "label_id", flat=True
+ )
+ ),
+ many=True,
+ ).data
else:
- data["labels"] = [str(label.id) for label in instance.labels.all()]
+ data["labels"] = [
+ str(label)
+ for label in IssueLabel.objects.filter(issue=instance).values_list(
+ "label_id", flat=True
+ )
+ ]
return data
diff --git a/apiserver/plane/api/views/intake.py b/apiserver/plane/api/views/intake.py
index c2d0733ba9..faefc3761c 100644
--- a/apiserver/plane/api/views/intake.py
+++ b/apiserver/plane/api/views/intake.py
@@ -109,16 +109,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
- # Create or get state
- state, _ = State.objects.get_or_create(
- name="Triage",
- group="triage",
- description="Default state for managing all Intake Issues",
- project_id=project_id,
- color="#ff7700",
- is_triage=True,
- )
-
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -128,7 +118,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
- state=state,
)
# create an intake issue
diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py
index c52caac0fd..527f43edb5 100644
--- a/apiserver/plane/api/views/project.py
+++ b/apiserver/plane/api/views/project.py
@@ -259,7 +259,7 @@ class ProjectAPIEndpoint(BaseAPIView):
)
intake_view = request.data.get(
- "inbox_view", request.data.get("intake_view", False)
+ "inbox_view", request.data.get("intake_view", project.intake_view)
)
if project.archived_at:
diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py
index 2e7022688a..cd9adb939e 100644
--- a/apiserver/plane/app/serializers/__init__.py
+++ b/apiserver/plane/app/serializers/__init__.py
@@ -13,7 +13,6 @@ from .user import (
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
- TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py
index bf08de4fef..28ec62134c 100644
--- a/apiserver/plane/app/serializers/cycle.py
+++ b/apiserver/plane/app/serializers/cycle.py
@@ -5,6 +5,7 @@ from rest_framework import serializers
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
+from plane.utils.timezone_converter import convert_to_utc
class CycleWriteSerializer(BaseSerializer):
@@ -15,6 +16,17 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
+ if (
+ data.get("start_date", None) is not None
+ and data.get("end_date", None) is not None
+ ):
+ project_id = self.initial_data.get("project_id") or self.instance.project_id
+ data["start_date"] = convert_to_utc(
+ str(data.get("start_date").date()), project_id, is_start_date=True
+ )
+ data["end_date"] = convert_to_utc(
+ str(data.get("end_date", None).date()), project_id
+ )
return data
class Meta:
diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py
index 03e64e32ac..49cd55bf7f 100644
--- a/apiserver/plane/app/serializers/workspace.py
+++ b/apiserver/plane/app/serializers/workspace.py
@@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
- User,
Workspace,
WorkspaceMember,
- Team,
- TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
@@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
]
-class TeamSerializer(BaseSerializer):
- members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
- members = serializers.ListField(
- child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
- write_only=True,
- required=False,
- )
-
- class Meta:
- model = Team
- fields = "__all__"
- read_only_fields = [
- "workspace",
- "created_by",
- "updated_by",
- "created_at",
- "updated_at",
- ]
-
- def create(self, validated_data, **kwargs):
- if "members" in validated_data:
- members = validated_data.pop("members")
- workspace = self.context["workspace"]
- team = Team.objects.create(**validated_data, workspace=workspace)
- team_members = [
- TeamMember(member=member, team=team, workspace=workspace)
- for member in members
- ]
- TeamMember.objects.bulk_create(team_members, batch_size=10)
- return team
- team = Team.objects.create(**validated_data)
- return team
-
- def update(self, instance, validated_data):
- if "members" in validated_data:
- members = validated_data.pop("members")
- TeamMember.objects.filter(team=instance).delete()
- team_members = [
- TeamMember(member=member, team=instance, workspace=instance.workspace)
- for member in members
- ]
- TeamMember.objects.bulk_create(team_members, batch_size=10)
- return super().update(instance, validated_data)
- return super().update(instance, validated_data)
-
-
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py
index 8798e80440..3be75536b2 100644
--- a/apiserver/plane/app/urls/__init__.py
+++ b/apiserver/plane/app/urls/__init__.py
@@ -17,6 +17,7 @@ from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
+from .timezone import urlpatterns as timezone_urls
urlpatterns = [
*analytic_urls,
@@ -38,4 +39,5 @@ urlpatterns = [
*workspace_urls,
*api_urls,
*webhook_urls,
+ *timezone_urls,
]
diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py
index 3f41caee81..4037402abf 100644
--- a/apiserver/plane/app/urls/project.py
+++ b/apiserver/plane/app/urls/project.py
@@ -7,7 +7,6 @@ from plane.app.views import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
- AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
@@ -83,11 +82,6 @@ urlpatterns = [
ProjectMemberViewSet.as_view({"post": "leave"}),
name="project-member",
),
- path(
- "workspaces//projects//team-invite/",
- AddTeamToProjectEndpoint.as_view(),
- name="projects",
- ),
path(
"workspaces//projects//project-views/",
ProjectUserViewsEndpoint.as_view(),
diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py
index bbea8093dd..de4f1e7b2c 100644
--- a/apiserver/plane/app/urls/search.py
+++ b/apiserver/plane/app/urls/search.py
@@ -1,7 +1,7 @@
from django.urls import path
-from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint
+from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
urlpatterns = [
@@ -15,4 +15,9 @@ urlpatterns = [
IssueSearchEndpoint.as_view(),
name="project-issue-search",
),
+ path(
+ "workspaces//projects//entity-search/",
+ SearchEndpoint.as_view(),
+ name="entity-search",
+ ),
]
diff --git a/apiserver/plane/app/urls/timezone.py b/apiserver/plane/app/urls/timezone.py
new file mode 100644
index 0000000000..ff14d029f2
--- /dev/null
+++ b/apiserver/plane/app/urls/timezone.py
@@ -0,0 +1,8 @@
+from django.urls import path
+
+from plane.app.views import TimezoneEndpoint
+
+urlpatterns = [
+ # timezone endpoint
+ path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list")
+]
diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py
index 00e9969953..d91fdb60bc 100644
--- a/apiserver/plane/app/urls/workspace.py
+++ b/apiserver/plane/app/urls/workspace.py
@@ -10,7 +10,6 @@ from plane.app.views import (
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
- TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
@@ -100,23 +99,6 @@ urlpatterns = [
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
name="leave-workspace-members",
),
- path(
- "workspaces//teams/",
- TeamMemberViewSet.as_view({"get": "list", "post": "create"}),
- name="workspace-team-members",
- ),
- path(
- "workspaces//teams//",
- TeamMemberViewSet.as_view(
- {
- "put": "update",
- "patch": "partial_update",
- "delete": "destroy",
- "get": "retrieve",
- }
- ),
- name="workspace-team-members",
- ),
path(
"users/last-visited-workspace/",
UserLastProjectWithWorkspaceEndpoint.as_view(),
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index c21000a4eb..56ea78b413 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -16,7 +16,6 @@ from .project.invite import (
from .project.member import (
ProjectMemberViewSet,
- AddTeamToProjectEndpoint,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
)
@@ -49,7 +48,6 @@ from .workspace.favorite import (
from .workspace.member import (
WorkSpaceMemberViewSet,
- TeamMemberViewSet,
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -88,8 +86,6 @@ from .cycle.base import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
- CycleViewSet,
- TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
@@ -162,7 +158,7 @@ from .page.base import (
)
from .page.version import PageVersionEndpoint
-from .search.base import GlobalSearchEndpoint
+from .search.base import GlobalSearchEndpoint, SearchEndpoint
from .search.issue import IssueSearchEndpoint
@@ -206,6 +202,7 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
-from .exporter.base import ExportIssuesEndpoint
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
+
+from .timezone.base import TimezoneEndpoint
diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py
index 827c959084..3c1442022f 100644
--- a/apiserver/plane/app/views/asset/v2.py
+++ b/apiserver/plane/app/views/asset/v2.py
@@ -126,7 +126,13 @@ class UserAssetsV2Endpoint(BaseAPIView):
)
# Check if the file type is allowed
- allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
+ allowed_types = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/jpg",
+ "image/gif",
+ ]
if type not in allowed_types:
return Response(
{
diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py
index 61ea9eed46..9bf498886b 100644
--- a/apiserver/plane/app/views/cycle/base.py
+++ b/apiserver/plane/app/views/cycle/base.py
@@ -1,5 +1,7 @@
# Python imports
import json
+import pytz
+
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@@ -52,6 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
+from plane.utils.timezone_converter import (
+ convert_utc_to_project_timezone,
+ convert_to_utc,
+ user_timezone_converter,
+)
class CycleViewSet(BaseViewSet):
@@ -67,6 +74,19 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
+
+ project = Project.objects.get(id=self.kwargs.get("project_id"))
+
+ # Fetch project for the specific record or pass project_id dynamically
+ project_timezone = project.timezone
+
+ # Convert the current time (timezone.now()) to the project's timezone
+ local_tz = pytz.timezone(project_timezone)
+ current_time_in_project_tz = timezone.now().astimezone(local_tz)
+
+ # Convert project local time back to UTC for comparison (start_date is stored in UTC)
+ current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
+
return self.filter_queryset(
super()
.get_queryset()
@@ -119,12 +139,15 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
- Q(start_date__lte=timezone.now())
- & Q(end_date__gte=timezone.now()),
+ Q(start_date__lte=current_time_in_utc)
+ & Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
- When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
- When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
+ When(
+ start_date__gt=current_time_in_utc,
+ then=Value("UPCOMING"),
+ ),
+ When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
@@ -160,10 +183,22 @@ class CycleViewSet(BaseViewSet):
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
+ project = Project.objects.get(id=self.kwargs.get("project_id"))
+
+ # Fetch project for the specific record or pass project_id dynamically
+ project_timezone = project.timezone
+
+ # Convert the current time (timezone.now()) to the project's timezone
+ local_tz = pytz.timezone(project_timezone)
+ current_time_in_project_tz = timezone.now().astimezone(local_tz)
+
+ # Convert project local time back to UTC for comparison (start_date is stored in UTC)
+ current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
+
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
- start_date__lte=timezone.now(), end_date__gte=timezone.now()
+ start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
data = queryset.values(
@@ -191,6 +226,8 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
+ datetime_fields = ["start_date", "end_date"]
+ data = user_timezone_converter(data, datetime_fields, project_timezone)
if data:
return Response(data, status=status.HTTP_200_OK)
@@ -221,6 +258,8 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
+ datetime_fields = ["start_date", "end_date"]
+ data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -417,6 +456,8 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
+ datetime_fields = ["start_date", "end_date"]
+ data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -492,6 +533,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
+ start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
+ end_date = convert_to_utc(str(end_date), project_id)
+
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index 3d548aeacc..01f4068eee 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -15,8 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
- Case,
- When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -56,10 +54,11 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from .. import BaseAPIView, BaseViewSet
-from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
+from plane.bgtasks.issue_description_version_task import issue_description_version_task
class IssueListEndpoint(BaseAPIView):
@@ -430,6 +429,13 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
+ # updated issue description version
+ issue_description_version_task.delay(
+ updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
+ issue_id=str(serializer.data["id"]),
+ user_id=request.user.id,
+ is_creating=True,
+ )
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -445,12 +451,10 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
- cycle_id=Case(
- When(
- issue_cycle__cycle__deleted_at__isnull=True,
- then=F("issue_cycle__cycle_id"),
- ),
- default=None,
+ cycle_id=Subquery(
+ CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
+ :1
+ ]
)
)
.annotate(
@@ -653,6 +657,12 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
+ # updated issue description version
+ issue_description_version_task.delay(
+ updated_issue=current_instance,
+ issue_id=str(serializer.data.get("id", None)),
+ user_id=request.user.id,
+ )
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py
index e461917fb3..19e2522d2c 100644
--- a/apiserver/plane/app/views/issue/sub_issue.py
+++ b/apiserver/plane/app/views/issue/sub_issue.py
@@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.bgtasks.issue_activities_task import issue_activity
-from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py
index 82c1d47eb4..d5c632f966 100644
--- a/apiserver/plane/app/views/module/archive.py
+++ b/apiserver/plane/app/views/module/archive.py
@@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ModuleDetailSerializer
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
+from plane.utils.timezone_converter import user_timezone_converter
# Module imports
diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py
index 8f9839b71f..3e3a4c2db7 100644
--- a/apiserver/plane/app/views/module/base.py
+++ b/apiserver/plane/app/views/module/base.py
@@ -56,7 +56,7 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
-from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index 24ceb2d3f1..46ce81ce17 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -114,7 +114,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -134,7 +134,7 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -234,7 +234,7 @@ class PageViewSet(BaseViewSet):
)
return Response(data, status=status.HTTP_200_OK)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +244,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +255,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -296,7 +296,7 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -323,7 +323,7 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -348,7 +348,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
- @allow_permission([ROLE.ADMIN], creator=True, model=Page)
+ @allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py
index 879d3ed3c9..16b2a6e775 100644
--- a/apiserver/plane/app/views/project/base.py
+++ b/apiserver/plane/app/views/project/base.py
@@ -384,11 +384,9 @@ class ProjectViewSet(BaseViewSet):
)
workspace = Workspace.objects.get(slug=slug)
- intake_view = request.data.get(
- "inbox_view", request.data.get("intake_view", False)
- )
project = Project.objects.get(pk=pk)
+ intake_view = request.data.get("inbox_view", project.intake_view)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py
index d36036b98f..e4d46e89f4 100644
--- a/apiserver/plane/app/views/project/invite.py
+++ b/apiserver/plane/app/views/project/invite.py
@@ -136,7 +136,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
member=request.user, workspace__slug=slug, is_active=True
)
- if workspace_member.role != ROLE.ADMIN:
+ if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
return Response(
{"error": "You do not have permission to join the project"},
status=status.HTTP_403_FORBIDDEN,
diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py
index f46bf6c501..55d2d4a58b 100644
--- a/apiserver/plane/app/views/project/member.py
+++ b/apiserver/plane/app/views/project/member.py
@@ -11,20 +11,12 @@ from plane.app.serializers import (
)
from plane.app.permissions import (
- ProjectBasePermission,
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
-from plane.db.models import (
- Project,
- ProjectMember,
- Workspace,
- TeamMember,
- IssueUserProperty,
- WorkspaceMember,
-)
+from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -309,53 +301,6 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
-class AddTeamToProjectEndpoint(BaseAPIView):
- permission_classes = [ProjectBasePermission]
-
- def post(self, request, slug, project_id):
- team_members = TeamMember.objects.filter(
- workspace__slug=slug, team__in=request.data.get("teams", [])
- ).values_list("member", flat=True)
-
- if len(team_members) == 0:
- return Response(
- {"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
- )
-
- workspace = Workspace.objects.get(slug=slug)
-
- project_members = []
- issue_props = []
- for member in team_members:
- project_members.append(
- ProjectMember(
- project_id=project_id,
- member_id=member,
- workspace=workspace,
- created_by=request.user,
- )
- )
- issue_props.append(
- IssueUserProperty(
- project_id=project_id,
- user_id=member,
- workspace=workspace,
- created_by=request.user,
- )
- )
-
- ProjectMember.objects.bulk_create(
- project_members, batch_size=10, ignore_conflicts=True
- )
-
- _ = IssueUserProperty.objects.bulk_create(
- issue_props, batch_size=10, ignore_conflicts=True
- )
-
- serializer = ProjectMemberSerializer(project_members, many=True)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
-
-
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py
index 5161103f54..3736d8f81a 100644
--- a/apiserver/plane/app/views/search/base.py
+++ b/apiserver/plane/app/views/search/base.py
@@ -2,10 +2,21 @@
import re
# Django imports
-from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
+from django.db import models
+from django.db.models import (
+ Q,
+ OuterRef,
+ Subquery,
+ Value,
+ UUIDField,
+ CharField,
+ When,
+ Case,
+)
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, Concat
+from django.utils import timezone
# Third party imports
from rest_framework import status
@@ -21,6 +32,7 @@ from plane.db.models import (
Module,
Page,
IssueView,
+ ProjectMember,
ProjectPage,
)
@@ -237,3 +249,217 @@ 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 SearchEndpoint(BaseAPIView):
+ def get(self, request, slug, project_id):
+ query = request.query_params.get("query", False)
+ query_types = request.query_params.get("query_type", "user_mention").split(",")
+ query_types = [qt.strip() for qt in query_types]
+ count = int(request.query_params.get("count", 5))
+
+ response_data = {}
+
+ for query_type in query_types:
+ if query_type == "user_mention":
+ fields = [
+ "member__first_name",
+ "member__last_name",
+ "member__display_name",
+ ]
+ q = Q()
+
+ if query:
+ for field in fields:
+ q |= Q(**{f"{field}__icontains": query})
+ users = (
+ ProjectMember.objects.filter(
+ q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False
+ )
+ .annotate(
+ member__avatar_url=Case(
+ When(
+ member__avatar_asset__isnull=False,
+ then=Concat(
+ Value("/api/assets/v2/static/"),
+ "member__avatar_asset",
+ Value("/"),
+ ),
+ ),
+ When(
+ member__avatar_asset__isnull=True, then="member__avatar"
+ ),
+ default=Value(None),
+ output_field=models.CharField(),
+ )
+ )
+ .order_by("-created_at")
+ .values("member__avatar_url", "member__display_name", "member__id")[
+ :count
+ ]
+ )
+ response_data["user_mention"] = list(users)
+
+ elif query_type == "project":
+ fields = ["name", "identifier"]
+ q = Q()
+
+ if query:
+ for field in fields:
+ q |= Q(**{f"{field}__icontains": query})
+ projects = (
+ Project.objects.filter(
+ q,
+ Q(project_projectmember__member=self.request.user)
+ | Q(network=2),
+ workspace__slug=slug,
+ )
+ .order_by("-created_at")
+ .distinct()
+ .values(
+ "name", "id", "identifier", "logo_props", "workspace__slug"
+ )[:count]
+ )
+ response_data["project"] = list(projects)
+
+ elif query_type == "issue":
+ fields = ["name", "sequence_id", "project__identifier"]
+ q = Q()
+
+ if query:
+ for field in fields:
+ if field == "sequence_id":
+ sequences = re.findall(r"\b\d+\b", query)
+ for sequence_id in sequences:
+ q |= Q(**{"sequence_id": sequence_id})
+ else:
+ q |= Q(**{f"{field}__icontains": query})
+
+ issues = (
+ Issue.issue_objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .order_by("-created_at")
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "sequence_id",
+ "project__identifier",
+ "project_id",
+ "priority",
+ "state_id",
+ "type_id",
+ )[:count]
+ )
+ response_data["issue"] = list(issues)
+
+ elif query_type == "cycle":
+ fields = ["name"]
+ q = Q()
+
+ if query:
+ for field in fields:
+ q |= Q(**{f"{field}__icontains": query})
+
+ cycles = (
+ Cycle.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ workspace__slug=slug,
+ )
+ .annotate(
+ status=Case(
+ When(
+ Q(start_date__lte=timezone.now())
+ & Q(end_date__gte=timezone.now()),
+ then=Value("CURRENT"),
+ ),
+ When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
+ When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
+ When(
+ Q(start_date__isnull=True) & Q(end_date__isnull=True),
+ then=Value("DRAFT"),
+ ),
+ default=Value("DRAFT"),
+ output_field=CharField(),
+ )
+ )
+ .order_by("-created_at")
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "status",
+ "workspace__slug",
+ )[:count]
+ )
+ response_data["cycle"] = list(cycles)
+
+ elif query_type == "module":
+ fields = ["name"]
+ q = Q()
+
+ if query:
+ for field in fields:
+ q |= Q(**{f"{field}__icontains": query})
+
+ modules = (
+ Module.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ workspace__slug=slug,
+ )
+ .order_by("-created_at")
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "project__identifier",
+ "status",
+ "workspace__slug",
+ )[:count]
+ )
+ response_data["module"] = list(modules)
+
+ elif query_type == "page":
+ fields = ["name"]
+ q = Q()
+
+ if query:
+ for field in fields:
+ q |= Q(**{f"{field}__icontains": query})
+
+ pages = (
+ Page.objects.filter(
+ q,
+ projects__project_projectmember__member=self.request.user,
+ projects__project_projectmember__is_active=True,
+ projects__id=project_id,
+ workspace__slug=slug,
+ access=0,
+ )
+ .order_by("-created_at")
+ .distinct()
+ .values(
+ "name", "id", "logo_props", "projects__id", "workspace__slug"
+ )[:count]
+ )
+ response_data["page"] = list(pages)
+
+ else:
+ return Response(
+ {"error": f"Invalid query type: {query_type}"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ return Response(response_data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py
new file mode 100644
index 0000000000..77c8770473
--- /dev/null
+++ b/apiserver/plane/app/views/timezone/base.py
@@ -0,0 +1,247 @@
+# Python imports
+import pytz
+from datetime import datetime
+
+# Django imports
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_page
+
+# Third party imports
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.permissions import AllowAny
+from rest_framework.views import APIView
+
+# Module imports
+from plane.authentication.rate_limit import AuthenticationThrottle
+
+
+class TimezoneEndpoint(APIView):
+ permission_classes = [AllowAny]
+
+ throttle_classes = [AuthenticationThrottle]
+
+ @method_decorator(cache_page(60 * 60 * 24))
+ def get(self, request):
+ timezone_mapping = {
+ "-1100": [
+ ("Midway Island", "Pacific/Midway"),
+ ("American Samoa", "Pacific/Pago_Pago"),
+ ],
+ "-1000": [
+ ("Hawaii", "Pacific/Honolulu"),
+ ("Aleutian Islands", "America/Adak"),
+ ],
+ "-0930": [("Marquesas Islands", "Pacific/Marquesas")],
+ "-0900": [
+ ("Alaska", "America/Anchorage"),
+ ("Gambier Islands", "Pacific/Gambier"),
+ ],
+ "-0800": [
+ ("Pacific Time (US and Canada)", "America/Los_Angeles"),
+ ("Baja California", "America/Tijuana"),
+ ],
+ "-0700": [
+ ("Mountain Time (US and Canada)", "America/Denver"),
+ ("Arizona", "America/Phoenix"),
+ ("Chihuahua, Mazatlan", "America/Chihuahua"),
+ ],
+ "-0600": [
+ ("Central Time (US and Canada)", "America/Chicago"),
+ ("Saskatchewan", "America/Regina"),
+ ("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
+ ("Tegucigalpa, Honduras", "America/Tegucigalpa"),
+ ("Costa Rica", "America/Costa_Rica"),
+ ],
+ "-0500": [
+ ("Eastern Time (US and Canada)", "America/New_York"),
+ ("Lima", "America/Lima"),
+ ("Bogota", "America/Bogota"),
+ ("Quito", "America/Guayaquil"),
+ ("Chetumal", "America/Cancun"),
+ ],
+ "-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
+ "-0400": [
+ ("Atlantic Time (Canada)", "America/Halifax"),
+ ("Caracas", "America/Caracas"),
+ ("Santiago", "America/Santiago"),
+ ("La Paz", "America/La_Paz"),
+ ("Manaus", "America/Manaus"),
+ ("Georgetown", "America/Guyana"),
+ ("Bermuda", "Atlantic/Bermuda"),
+ ],
+ "-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
+ "-0300": [
+ ("Buenos Aires", "America/Argentina/Buenos_Aires"),
+ ("Brasilia", "America/Sao_Paulo"),
+ ("Greenland", "America/Godthab"),
+ ("Montevideo", "America/Montevideo"),
+ ("Falkland Islands", "Atlantic/Stanley"),
+ ],
+ "-0200": [
+ (
+ "South Georgia and the South Sandwich Islands",
+ "Atlantic/South_Georgia",
+ )
+ ],
+ "-0100": [
+ ("Azores", "Atlantic/Azores"),
+ ("Cape Verde Islands", "Atlantic/Cape_Verde"),
+ ],
+ "+0000": [
+ ("Dublin", "Europe/Dublin"),
+ ("Reykjavik", "Atlantic/Reykjavik"),
+ ("Lisbon", "Europe/Lisbon"),
+ ("Monrovia", "Africa/Monrovia"),
+ ("Casablanca", "Africa/Casablanca"),
+ ],
+ "+0100": [
+ ("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
+ ("West Central Africa", "Africa/Lagos"),
+ ("Algiers", "Africa/Algiers"),
+ ("Lagos", "Africa/Lagos"),
+ ("Tunis", "Africa/Tunis"),
+ ],
+ "+0200": [
+ ("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
+ ("Athens", "Europe/Athens"),
+ ("Jerusalem", "Asia/Jerusalem"),
+ ("Johannesburg", "Africa/Johannesburg"),
+ ("Harare, Pretoria", "Africa/Harare"),
+ ],
+ "+0300": [
+ ("Moscow Time", "Europe/Moscow"),
+ ("Baghdad", "Asia/Baghdad"),
+ ("Nairobi", "Africa/Nairobi"),
+ ("Kuwait, Riyadh", "Asia/Riyadh"),
+ ],
+ "+0330": [("Tehran", "Asia/Tehran")],
+ "+0400": [
+ ("Abu Dhabi", "Asia/Dubai"),
+ ("Baku", "Asia/Baku"),
+ ("Yerevan", "Asia/Yerevan"),
+ ("Astrakhan", "Europe/Astrakhan"),
+ ("Tbilisi", "Asia/Tbilisi"),
+ ("Mauritius", "Indian/Mauritius"),
+ ],
+ "+0500": [
+ ("Islamabad", "Asia/Karachi"),
+ ("Karachi", "Asia/Karachi"),
+ ("Tashkent", "Asia/Tashkent"),
+ ("Yekaterinburg", "Asia/Yekaterinburg"),
+ ("Maldives", "Indian/Maldives"),
+ ("Chagos", "Indian/Chagos"),
+ ],
+ "+0530": [
+ ("Chennai", "Asia/Kolkata"),
+ ("Kolkata", "Asia/Kolkata"),
+ ("Mumbai", "Asia/Kolkata"),
+ ("New Delhi", "Asia/Kolkata"),
+ ("Sri Jayawardenepura", "Asia/Colombo"),
+ ],
+ "+0545": [("Kathmandu", "Asia/Kathmandu")],
+ "+0600": [
+ ("Dhaka", "Asia/Dhaka"),
+ ("Almaty", "Asia/Almaty"),
+ ("Bishkek", "Asia/Bishkek"),
+ ("Thimphu", "Asia/Thimphu"),
+ ],
+ "+0630": [
+ ("Yangon (Rangoon)", "Asia/Yangon"),
+ ("Cocos Islands", "Indian/Cocos"),
+ ],
+ "+0700": [
+ ("Bangkok", "Asia/Bangkok"),
+ ("Hanoi", "Asia/Ho_Chi_Minh"),
+ ("Jakarta", "Asia/Jakarta"),
+ ("Novosibirsk", "Asia/Novosibirsk"),
+ ("Krasnoyarsk", "Asia/Krasnoyarsk"),
+ ],
+ "+0800": [
+ ("Beijing", "Asia/Shanghai"),
+ ("Singapore", "Asia/Singapore"),
+ ("Perth", "Australia/Perth"),
+ ("Hong Kong", "Asia/Hong_Kong"),
+ ("Ulaanbaatar", "Asia/Ulaanbaatar"),
+ ("Palau", "Pacific/Palau"),
+ ],
+ "+0845": [("Eucla", "Australia/Eucla")],
+ "+0900": [
+ ("Tokyo", "Asia/Tokyo"),
+ ("Seoul", "Asia/Seoul"),
+ ("Yakutsk", "Asia/Yakutsk"),
+ ],
+ "+0930": [
+ ("Adelaide", "Australia/Adelaide"),
+ ("Darwin", "Australia/Darwin"),
+ ],
+ "+1000": [
+ ("Sydney", "Australia/Sydney"),
+ ("Brisbane", "Australia/Brisbane"),
+ ("Guam", "Pacific/Guam"),
+ ("Vladivostok", "Asia/Vladivostok"),
+ ("Tahiti", "Pacific/Tahiti"),
+ ],
+ "+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
+ "+1100": [
+ ("Solomon Islands", "Pacific/Guadalcanal"),
+ ("Magadan", "Asia/Magadan"),
+ ("Norfolk Island", "Pacific/Norfolk"),
+ ("Bougainville Island", "Pacific/Bougainville"),
+ ("Chokurdakh", "Asia/Srednekolymsk"),
+ ],
+ "+1200": [
+ ("Auckland", "Pacific/Auckland"),
+ ("Wellington", "Pacific/Auckland"),
+ ("Fiji Islands", "Pacific/Fiji"),
+ ("Anadyr", "Asia/Anadyr"),
+ ],
+ "+1245": [("Chatham Islands", "Pacific/Chatham")],
+ "+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
+ "+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
+ }
+
+ timezone_list = []
+ now = datetime.now()
+
+ # Process timezone mapping
+ for offset, locations in timezone_mapping.items():
+ sign = "-" if offset.startswith("-") else "+"
+ hours = offset[1:3]
+ minutes = offset[3:] if len(offset) > 3 else "00"
+
+ for friendly_name, tz_identifier in locations:
+ try:
+ tz = pytz.timezone(tz_identifier)
+ current_offset = now.astimezone(tz).strftime("%z")
+
+ # converting and formatting UTC offset to GMT offset
+ current_utc_offset = now.astimezone(tz).utcoffset()
+ total_seconds = int(current_utc_offset.total_seconds())
+ hours_offset = total_seconds // 3600
+ minutes_offset = abs(total_seconds % 3600) // 60
+ gmt_offset = (
+ f"GMT{'+' if hours_offset >= 0 else '-'}"
+ f"{abs(hours_offset):02}:{minutes_offset:02}"
+ )
+
+ timezone_value = {
+ "offset": int(current_offset),
+ "utc_offset": f"UTC{sign}{hours}:{minutes}",
+ "gmt_offset": gmt_offset,
+ "value": tz_identifier,
+ "label": f"{friendly_name}",
+ }
+
+ timezone_list.append(timezone_value)
+ except pytz.exceptions.UnknownTimeZoneError:
+ continue
+
+ # Sort by offset and then by label
+ timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
+
+ # Remove offset from final output
+ for tz in timezone_list:
+ del tz["offset"]
+
+ return Response({"timezones": timezone_list}, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py
index 6ffb643a99..058f7702ab 100644
--- a/apiserver/plane/app/views/workspace/base.py
+++ b/apiserver/plane/app/views/workspace/base.py
@@ -1,6 +1,7 @@
# Python imports
import csv
import io
+import os
from datetime import date
from dateutil.relativedelta import relativedelta
@@ -38,6 +39,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
+from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
@@ -80,6 +82,21 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
+ (DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
+ [
+ {
+ "key": "DISABLE_WORKSPACE_CREATION",
+ "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
+ }
+ ]
+ )
+
+ if DISABLE_WORKSPACE_CREATION == "1":
+ return Response(
+ {"error": "Workspace creation is not allowed"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
serializer = WorkSpaceSerializer(data=request.data)
slug = request.data.get("slug", False)
@@ -337,6 +354,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
workspace__slug=slug,
created_at__date=request.data.get("date"),
project__project_projectmember__member=request.user,
+ project__project_projectmember__is_active=True,
actor_id=user_id,
).select_related("actor", "workspace", "issue", "project")[:10000]
diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py
index ec08f47c95..a9398a91db 100644
--- a/apiserver/plane/app/views/workspace/cycle.py
+++ b/apiserver/plane/app/views/workspace/cycle.py
@@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
-
+from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py
index 3f5a4bd2b2..9541f99803 100644
--- a/apiserver/plane/app/views/workspace/member.py
+++ b/apiserver/plane/app/views/workspace/member.py
@@ -1,38 +1,22 @@
# Django imports
-from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField
+from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
-from django.db.models.functions import Cast
# Third party modules
from rest_framework import status
from rest_framework.response import Response
-from plane.app.permissions import (
- WorkSpaceAdminPermission,
- WorkspaceEntityPermission,
- allow_permission,
- ROLE,
-)
+from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
# Module imports
from plane.app.serializers import (
ProjectMemberRoleSerializer,
- TeamSerializer,
- UserLiteSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
-from plane.db.models import (
- Project,
- ProjectMember,
- Team,
- User,
- Workspace,
- WorkspaceMember,
- DraftIssue,
-)
+from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -284,53 +268,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members_dict[str(project_id)].append(project_member)
return Response(project_members_dict, status=status.HTTP_200_OK)
-
-
-class TeamMemberViewSet(BaseViewSet):
- serializer_class = TeamSerializer
- model = Team
- permission_classes = [WorkSpaceAdminPermission]
-
- search_fields = ["member__display_name", "member__first_name"]
-
- def get_queryset(self):
- return self.filter_queryset(
- super()
- .get_queryset()
- .filter(workspace__slug=self.kwargs.get("slug"))
- .select_related("workspace", "workspace__owner")
- .prefetch_related("members")
- )
-
- def create(self, request, slug):
- members = list(
- WorkspaceMember.objects.filter(
- workspace__slug=slug,
- member__id__in=request.data.get("members", []),
- is_active=True,
- )
- .annotate(member_str_id=Cast("member", output_field=CharField()))
- .distinct()
- .values_list("member_str_id", flat=True)
- )
-
- if len(members) != len(request.data.get("members", [])):
- users = list(set(request.data.get("members", [])).difference(members))
- users = User.objects.filter(pk__in=users)
-
- serializer = UserLiteSerializer(users, many=True)
- return Response(
- {
- "error": f"{len(users)} of the member(s) are not a part of the workspace",
- "members": serializer.data,
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- workspace = Workspace.objects.get(slug=slug)
-
- serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
- if serializer.is_valid():
- serializer.save()
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py
index c7e4b8a5e2..0ad1db61f3 100644
--- a/apiserver/plane/authentication/views/app/check.py
+++ b/apiserver/plane/authentication/views/app/check.py
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
+ # Lower the email
+ email = str(email).lower().strip()
+
# Validate email
try:
validate_email(email)
diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py
index e027843334..b3bf8c7776 100644
--- a/apiserver/plane/authentication/views/app/magic.py
+++ b/apiserver/plane/authentication/views/app/magic.py
@@ -44,10 +44,8 @@ class MagicGenerateEndpoint(APIView):
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = request.META.get("HTTP_ORIGIN", "/")
- email = request.data.get("email", False)
+ email = request.data.get("email", "").strip().lower()
try:
- # Clean up the email
- email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py
index 9b4d8aa56d..c8a4539b71 100644
--- a/apiserver/plane/authentication/views/space/check.py
+++ b/apiserver/plane/authentication/views/space/check.py
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
+ email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py
index e763aaa07c..7c23d5fc34 100644
--- a/apiserver/plane/authentication/views/space/magic.py
+++ b/apiserver/plane/authentication/views/space/magic.py
@@ -39,10 +39,8 @@ class MagicGenerateSpaceEndpoint(APIView):
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = base_host(request=request, is_space=True)
- email = request.data.get("email", False)
+ email = request.data.get("email", "").strip().lower()
try:
- # Clean up the email
- email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py
index 0752272e3c..30ff7e8bd3 100644
--- a/apiserver/plane/bgtasks/deletion_task.py
+++ b/apiserver/plane/bgtasks/deletion_task.py
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
-from django.core.exceptions import ObjectDoesNotExist
+from django.db.models.fields.related import OneToOneRel
+
# Third party imports
from celery import shared_task
@@ -11,31 +12,98 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
+ """
+ Soft delete related objects for a given model instance
+ """
+ # Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
- instance = model_class.all_objects.get(pk=instance_pk)
- related_fields = instance._meta.get_fields()
- for field in related_fields:
- if field.one_to_many or field.one_to_one:
- try:
- # Check if the field has CASCADE on delete
- if (
- not hasattr(field.remote_field, "on_delete")
- or field.remote_field.on_delete == models.CASCADE
- ):
- if field.one_to_many:
- related_objects = getattr(instance, field.name).all()
- elif field.one_to_one:
- related_object = getattr(instance, field.name)
- related_objects = (
- [related_object] if related_object is not None else []
- )
- for obj in related_objects:
- if obj:
- obj.deleted_at = timezone.now()
- obj.save(using=using)
- except ObjectDoesNotExist:
- pass
+ # Get the instance using all_objects to ensure we can get even if it's already soft deleted
+ try:
+ instance = model_class.all_objects.get(pk=instance_pk)
+ except model_class.DoesNotExist:
+ return
+
+ # Get all related fields that are reverse relationships
+ all_related = [
+ f
+ for f in instance._meta.get_fields()
+ if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
+ ]
+
+ # Handle each related field
+ for relation in all_related:
+ related_name = relation.get_accessor_name()
+
+ # Skip if the relation doesn't exist
+ if not hasattr(instance, related_name):
+ continue
+
+ # Get the on_delete behavior name
+ on_delete_name = (
+ relation.on_delete.__name__
+ if hasattr(relation.on_delete, "__name__")
+ else ""
+ )
+
+ if on_delete_name == "DO_NOTHING":
+ continue
+
+ elif on_delete_name == "SET_NULL":
+ # Handle SET_NULL relationships
+ if isinstance(relation, OneToOneRel):
+ # For OneToOne relationships
+ related_obj = getattr(instance, related_name, None)
+ if related_obj and isinstance(related_obj, models.Model):
+ setattr(related_obj, relation.remote_field.name, None)
+ related_obj.save(update_fields=[relation.remote_field.name])
+ else:
+ # For other relationships
+ related_queryset = getattr(instance, related_name).all()
+ related_queryset.update(**{relation.remote_field.name: None})
+
+ else:
+ # Handle CASCADE and other delete behaviors
+ try:
+ if relation.one_to_one:
+ # Handle OneToOne relationships
+ related_obj = getattr(instance, related_name, None)
+ if related_obj:
+ if hasattr(related_obj, "deleted_at"):
+ if not related_obj.deleted_at:
+ related_obj.deleted_at = timezone.now()
+ related_obj.save()
+ # Recursively handle related objects
+ soft_delete_related_objects(
+ related_obj._meta.app_label,
+ related_obj._meta.model_name,
+ related_obj.pk,
+ using,
+ )
+ else:
+ # Handle other relationships
+ related_queryset = getattr(instance, related_name).all()
+ for related_obj in related_queryset:
+ if hasattr(related_obj, "deleted_at"):
+ if not related_obj.deleted_at:
+ related_obj.deleted_at = timezone.now()
+ related_obj.save()
+ # Recursively handle related objects
+ soft_delete_related_objects(
+ related_obj._meta.app_label,
+ related_obj._meta.model_name,
+ related_obj.pk,
+ using,
+ )
+ except Exception as e:
+ # Log the error or handle as needed
+ print(f"Error handling relation {related_name}: {str(e)}")
+ continue
+
+ # Finally, soft delete the instance itself if it hasn't been deleted yet
+ if hasattr(instance, "deleted_at") and not instance.deleted_at:
+ instance.deleted_at = timezone.now()
+ instance.save()
# @shared_task
diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py
index f7b19f00ab..33e382f441 100644
--- a/apiserver/plane/bgtasks/export_task.py
+++ b/apiserver/plane/bgtasks/export_task.py
@@ -162,8 +162,7 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
- if issue["created_by__first_name"]
- and issue["created_by__last_name"]
+ if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
@@ -197,8 +196,7 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
- if issue["created_by__first_name"]
- and issue["created_by__last_name"]
+ if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -208,17 +206,11 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
- "Cycle Start Date": dateConverter(
- issue["issue_cycle__cycle__start_date"]
- ),
+ "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
- "Module Start Date": dateConverter(
- issue["issue_module__module__start_date"]
- ),
- "Module Target Date": dateConverter(
- issue["issue_module__module__target_date"]
- ),
+ "Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
+ "Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
diff --git a/apiserver/plane/bgtasks/issue_description_version_sync.py b/apiserver/plane/bgtasks/issue_description_version_sync.py
new file mode 100644
index 0000000000..14956cb50c
--- /dev/null
+++ b/apiserver/plane/bgtasks/issue_description_version_sync.py
@@ -0,0 +1,125 @@
+# Python imports
+from typing import Optional
+import logging
+
+# Django imports
+from django.utils import timezone
+from django.db import transaction
+
+# Third party imports
+from celery import shared_task
+
+# Module imports
+from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
+from plane.utils.exception_logger import log_exception
+
+
+def get_owner_id(issue: Issue) -> Optional[int]:
+ """Get the owner ID of the issue"""
+
+ if issue.updated_by_id:
+ return issue.updated_by_id
+
+ if issue.created_by_id:
+ return issue.created_by_id
+
+ # Find project admin as fallback
+ project_member = ProjectMember.objects.filter(
+ project_id=issue.project_id,
+ role=20, # Admin role
+ ).first()
+
+ return project_member.member_id if project_member else None
+
+
+@shared_task
+def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
+ """Task to create IssueDescriptionVersion records for existing Issues in batches"""
+ try:
+ with transaction.atomic():
+ base_query = Issue.objects
+ total_issues_count = base_query.count()
+
+ if total_issues_count == 0:
+ return
+
+ # Calculate batch range
+ end_offset = min(offset + batch_size, total_issues_count)
+
+ # Fetch issues with related data
+ issues_batch = (
+ base_query.order_by("created_at")
+ .select_related("workspace", "project")
+ .only(
+ "id",
+ "workspace_id",
+ "project_id",
+ "created_by_id",
+ "updated_by_id",
+ "description_binary",
+ "description_html",
+ "description_stripped",
+ "description",
+ )[offset:end_offset]
+ )
+
+ if not issues_batch:
+ return
+
+ version_objects = []
+ for issue in issues_batch:
+ # Validate required fields
+ if not issue.workspace_id or not issue.project_id:
+ logging.warning(
+ f"Skipping {issue.id} - missing workspace_id or project_id"
+ )
+ continue
+
+ # Determine owned_by_id
+ owned_by_id = get_owner_id(issue)
+ if owned_by_id is None:
+ logging.warning(f"Skipping issue {issue.id} - missing owned_by")
+ continue
+
+ # Create version object
+ version_objects.append(
+ IssueDescriptionVersion(
+ workspace_id=issue.workspace_id,
+ project_id=issue.project_id,
+ created_by_id=issue.created_by_id,
+ updated_by_id=issue.updated_by_id,
+ owned_by_id=owned_by_id,
+ last_saved_at=timezone.now(),
+ issue_id=issue.id,
+ description_binary=issue.description_binary,
+ description_html=issue.description_html,
+ description_stripped=issue.description_stripped,
+ description_json=issue.description,
+ )
+ )
+
+ # Bulk create version objects
+ if version_objects:
+ IssueDescriptionVersion.objects.bulk_create(version_objects)
+
+ # Schedule next batch if needed
+ if end_offset < total_issues_count:
+ sync_issue_description_version.apply_async(
+ kwargs={
+ "batch_size": batch_size,
+ "offset": end_offset,
+ "countdown": countdown,
+ },
+ countdown=countdown,
+ )
+ return
+ except Exception as e:
+ log_exception(e)
+ return
+
+
+@shared_task
+def schedule_issue_description_version(batch_size=5000, countdown=300):
+ sync_issue_description_version.delay(
+ batch_size=int(batch_size), countdown=countdown
+ )
diff --git a/apiserver/plane/bgtasks/issue_description_version_task.py b/apiserver/plane/bgtasks/issue_description_version_task.py
new file mode 100644
index 0000000000..a29fb6c572
--- /dev/null
+++ b/apiserver/plane/bgtasks/issue_description_version_task.py
@@ -0,0 +1,84 @@
+from celery import shared_task
+from django.db import transaction
+from django.utils import timezone
+from typing import Optional, Dict
+import json
+
+from plane.db.models import Issue, IssueDescriptionVersion
+from plane.utils.exception_logger import log_exception
+
+
+def should_update_existing_version(
+ version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
+) -> bool:
+ if not version:
+ return
+
+ time_difference = (timezone.now() - version.last_saved_at).total_seconds()
+ return (
+ str(version.owned_by_id) == str(user_id)
+ and time_difference <= max_time_difference
+ )
+
+
+def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
+ version.description_json = issue.description
+ version.description_html = issue.description_html
+ version.description_binary = issue.description_binary
+ version.description_stripped = issue.description_stripped
+ version.last_saved_at = timezone.now()
+
+ version.save(
+ update_fields=[
+ "description_json",
+ "description_html",
+ "description_binary",
+ "description_stripped",
+ "last_saved_at",
+ ]
+ )
+
+
+@shared_task
+def issue_description_version_task(
+ updated_issue, issue_id, user_id, is_creating=False
+) -> Optional[bool]:
+ try:
+ # Parse updated issue data
+ current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
+
+ # Get current issue
+ issue = Issue.objects.get(id=issue_id)
+
+ # Check if description has changed
+ if (
+ current_issue.get("description_html") == issue.description_html
+ and not is_creating
+ ):
+ return
+
+ with transaction.atomic():
+ # Get latest version
+ latest_version = (
+ IssueDescriptionVersion.objects.filter(issue_id=issue_id)
+ .order_by("-last_saved_at")
+ .first()
+ )
+
+ # Determine whether to update existing or create new version
+ if should_update_existing_version(version=latest_version, user_id=user_id):
+ update_existing_version(latest_version, issue)
+ else:
+ IssueDescriptionVersion.log_issue_description_version(issue, user_id)
+
+ return
+
+ except Issue.DoesNotExist:
+ # Issue no longer exists, skip processing
+ return
+ except json.JSONDecodeError as e:
+ log_exception(f"Invalid JSON for updated_issue: {e}")
+ return
+ except Exception as e:
+ log_exception(f"Error processing issue description version: {e}")
+ return
diff --git a/apiserver/plane/bgtasks/issue_version_sync.py b/apiserver/plane/bgtasks/issue_version_sync.py
new file mode 100644
index 0000000000..698cedf155
--- /dev/null
+++ b/apiserver/plane/bgtasks/issue_version_sync.py
@@ -0,0 +1,254 @@
+# Python imports
+import json
+from typing import Optional, List, Dict
+from uuid import UUID
+from itertools import groupby
+import logging
+
+# Django imports
+from django.utils import timezone
+from django.db import transaction
+
+# Third party imports
+from celery import shared_task
+
+# Module imports
+from plane.db.models import (
+ Issue,
+ IssueVersion,
+ ProjectMember,
+ CycleIssue,
+ ModuleIssue,
+ IssueActivity,
+ IssueAssignee,
+ IssueLabel,
+)
+from plane.utils.exception_logger import log_exception
+
+
+@shared_task
+def issue_task(updated_issue, issue_id, user_id):
+ try:
+ current_issue = json.loads(updated_issue) if updated_issue else {}
+ issue = Issue.objects.get(id=issue_id)
+
+ updated_current_issue = {}
+ for key, value in current_issue.items():
+ if getattr(issue, key) != value:
+ updated_current_issue[key] = value
+
+ if updated_current_issue:
+ issue_version = (
+ IssueVersion.objects.filter(issue_id=issue_id)
+ .order_by("-last_saved_at")
+ .first()
+ )
+
+ if (
+ issue_version
+ and str(issue_version.owned_by) == str(user_id)
+ and (timezone.now() - issue_version.last_saved_at).total_seconds()
+ <= 600
+ ):
+ for key, value in updated_current_issue.items():
+ setattr(issue_version, key, value)
+ issue_version.last_saved_at = timezone.now()
+ issue_version.save(
+ update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
+ )
+ else:
+ IssueVersion.log_issue_version(issue, user_id)
+
+ return
+ except Issue.DoesNotExist:
+ return
+ except Exception as e:
+ log_exception(e)
+ return
+
+
+def get_owner_id(issue: Issue) -> Optional[int]:
+ """Get the owner ID of the issue"""
+
+ if issue.updated_by_id:
+ return issue.updated_by_id
+
+ if issue.created_by_id:
+ return issue.created_by_id
+
+ # Find project admin as fallback
+ project_member = ProjectMember.objects.filter(
+ project_id=issue.project_id,
+ role=20, # Admin role
+ ).first()
+
+ return project_member.member_id if project_member else None
+
+
+def get_related_data(issue_ids: List[UUID]) -> Dict:
+ """Get related data for the given issue IDs"""
+
+ cycle_issues = {
+ ci.issue_id: ci.cycle_id
+ for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
+ }
+
+ # Get assignees with proper grouping
+ assignee_records = list(
+ IssueAssignee.objects.filter(issue_id__in=issue_ids)
+ .values_list("issue_id", "assignee_id")
+ .order_by("issue_id")
+ )
+ assignees = {}
+ for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
+ assignees[issue_id] = [str(g[1]) for g in group]
+
+ # Get labels with proper grouping
+ label_records = list(
+ IssueLabel.objects.filter(issue_id__in=issue_ids)
+ .values_list("issue_id", "label_id")
+ .order_by("issue_id")
+ )
+ labels = {}
+ for issue_id, group in groupby(label_records, key=lambda x: x[0]):
+ labels[issue_id] = [str(g[1]) for g in group]
+
+ # Get modules with proper grouping
+ module_records = list(
+ ModuleIssue.objects.filter(issue_id__in=issue_ids)
+ .values_list("issue_id", "module_id")
+ .order_by("issue_id")
+ )
+ modules = {}
+ for issue_id, group in groupby(module_records, key=lambda x: x[0]):
+ modules[issue_id] = [str(g[1]) for g in group]
+
+ # Get latest activities
+ latest_activities = {}
+ activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by(
+ "issue_id", "-created_at"
+ )
+ for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
+ first_activity = next(activities_group, None)
+ if first_activity:
+ latest_activities[issue_id] = first_activity.id
+
+ return {
+ "cycle_issues": cycle_issues,
+ "assignees": assignees,
+ "labels": labels,
+ "modules": modules,
+ "activities": latest_activities,
+ }
+
+
+def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
+ """Create IssueVersion object from the given issue and related data"""
+
+ try:
+ if not issue.workspace_id or not issue.project_id:
+ logging.warning(
+ f"Skipping issue {issue.id} - missing workspace_id or project_id"
+ )
+ return None
+
+ owned_by_id = get_owner_id(issue)
+ if owned_by_id is None:
+ logging.warning(f"Skipping issue {issue.id} - missing owned_by")
+ return None
+
+ return IssueVersion(
+ workspace_id=issue.workspace_id,
+ project_id=issue.project_id,
+ created_by_id=issue.created_by_id,
+ updated_by_id=issue.updated_by_id,
+ owned_by_id=owned_by_id,
+ last_saved_at=timezone.now(),
+ activity_id=related_data["activities"].get(issue.id),
+ properties=getattr(issue, "properties", {}),
+ meta=getattr(issue, "meta", {}),
+ issue_id=issue.id,
+ parent=issue.parent_id,
+ state=issue.state_id,
+ estimate_point=issue.estimate_point_id,
+ name=issue.name,
+ priority=issue.priority,
+ start_date=issue.start_date,
+ target_date=issue.target_date,
+ assignees=related_data["assignees"].get(issue.id, []),
+ sequence_id=issue.sequence_id,
+ labels=related_data["labels"].get(issue.id, []),
+ sort_order=issue.sort_order,
+ completed_at=issue.completed_at,
+ archived_at=issue.archived_at,
+ is_draft=issue.is_draft,
+ external_source=issue.external_source,
+ external_id=issue.external_id,
+ type=issue.type_id,
+ cycle=related_data["cycle_issues"].get(issue.id),
+ modules=related_data["modules"].get(issue.id, []),
+ )
+ except Exception as e:
+ log_exception(e)
+ return None
+
+
+@shared_task
+def sync_issue_version(batch_size=5000, offset=0, countdown=300):
+ """Task to create IssueVersion records for existing Issues in batches"""
+
+ try:
+ with transaction.atomic():
+ base_query = Issue.objects
+ total_issues_count = base_query.count()
+
+ if total_issues_count == 0:
+ return
+
+ end_offset = min(offset + batch_size, total_issues_count)
+
+ # Get issues batch with optimized queries
+ issues_batch = list(
+ base_query.order_by("created_at")
+ .select_related("workspace", "project")
+ .all()[offset:end_offset]
+ )
+
+ if not issues_batch:
+ return
+
+ # Get all related data in bulk
+ issue_ids = [issue.id for issue in issues_batch]
+ related_data = get_related_data(issue_ids)
+
+ issue_versions = []
+ for issue in issues_batch:
+ version = create_issue_version(issue, related_data)
+ if version:
+ issue_versions.append(version)
+
+ # Bulk create versions
+ if issue_versions:
+ IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
+
+ # Schedule the next batch if there are more workspaces to process
+ if end_offset < total_issues_count:
+ sync_issue_version.apply_async(
+ kwargs={
+ "batch_size": batch_size,
+ "offset": end_offset,
+ "countdown": countdown,
+ },
+ countdown=countdown,
+ )
+
+ logging.info(f"Processed Issues: {end_offset}")
+ return
+ except Exception as e:
+ log_exception(e)
+ return
+
+
+@shared_task
+def schedule_issue_version(batch_size=5000, countdown=300):
+ sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py
index e4a5417299..e58344bbf2 100644
--- a/apiserver/plane/bgtasks/notification_task.py
+++ b/apiserver/plane/bgtasks/notification_task.py
@@ -1,6 +1,8 @@
# Python imports
import json
import uuid
+from uuid import UUID
+
# Module imports
from plane.db.models import (
@@ -16,8 +18,9 @@ from plane.db.models import (
IssueComment,
IssueActivity,
UserNotificationPreference,
- ProjectMember
+ ProjectMember,
)
+from django.db.models import Subquery
# Third Party imports
from celery import shared_task
@@ -29,7 +32,6 @@ from bs4 import BeautifulSoup
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
-
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
@@ -95,7 +97,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
).exists()
and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
- ).exists() and ProjectMember.objects.filter(
+ ).exists()
+ and ProjectMember.objects.filter(
project_id=project_id, member_id=mention_id, is_active=True
).exists()
):
@@ -121,7 +124,9 @@ def extract_mentions(issue_instance):
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser")
- mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
+ mention_tags = soup.find_all(
+ "mention-component", attrs={"entity_name": "user_mention"}
+ )
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
@@ -135,7 +140,9 @@ def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
- mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
+ mentions_tags = soup.find_all(
+ "mention-component", attrs={"entity_name": "user_mention"}
+ )
for mention_tag in mentions_tags:
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
@@ -242,14 +249,18 @@ def notifications(
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
+ # get the list of active project members
+ project_members = ProjectMember.objects.filter(
+ project_id=project_id, is_active=True
+ ).values_list("member_id", flat=True)
+
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
- new_mentions = list(ProjectMember.objects.filter(
- project_id=project_id, member_id__in=new_mentions, is_active=True
- ).values_list("member_id", flat=True))
- new_mentions = [str(member_id) for member_id in new_mentions]
+ new_mentions = list(
+ set(new_mentions) & {str(member) for member in project_members}
+ )
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
)
@@ -280,6 +291,11 @@ def notifications(
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
+ comment_mentions = [
+ mention
+ for mention in comment_mentions
+ if UUID(mention) in set(project_members)
+ ]
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
@@ -293,7 +309,11 @@ def notifications(
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
- IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
+ IssueSubscriber.objects.filter(
+ project_id=project_id,
+ issue_id=issue_id,
+ subscriber__in=Subquery(project_members),
+ )
.exclude(
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
)
@@ -314,7 +334,9 @@ def notifications(
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
- issue_id=issue_id, project_id=project_id
+ issue_id=issue_id,
+ project_id=project_id,
+ assignee__in=Subquery(project_members),
).values_list("assignee", flat=True)
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
diff --git a/apiserver/plane/db/management/commands/create_project_member.py b/apiserver/plane/db/management/commands/create_project_member.py
index a2a5c669e4..927f97e9d5 100644
--- a/apiserver/plane/db/management/commands/create_project_member.py
+++ b/apiserver/plane/db/management/commands/create_project_member.py
@@ -13,28 +13,14 @@ from plane.db.models import (
class Command(BaseCommand):
-
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
+ parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
+ parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
- "--project_id",
- type=str,
- nargs="?",
- help="Project ID",
- )
- parser.add_argument(
- "--user_email",
- type=str,
- nargs="?",
- help="User Email",
- )
- parser.add_argument(
- "--role",
- type=int,
- nargs="?",
- help="Role of the user in the project",
+ "--role", type=int, nargs="?", help="Role of the user in the project"
)
def handle(self, *args: Any, **options: Any):
@@ -67,9 +53,7 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
- ProjectMember.objects.filter(
- workspace_id=project.workspace_id,
- )
+ ProjectMember.objects.filter(workspace_id=project.workspace_id)
.order_by("sort_order")
.first()
)
@@ -79,22 +63,15 @@ class Command(BaseCommand):
else:
sort_order = 65535
- if ProjectMember.objects.filter(
- project=project,
- member=user,
- ).exists():
+ if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
- ProjectMember.objects.filter(
- project=project,
- member=user,
- ).update(is_active=True, sort_order=sort_order, role=role)
+ ProjectMember.objects.filter(project=project, member=user).update(
+ is_active=True, sort_order=sort_order, role=role
+ )
else:
# Create the project member
ProjectMember.objects.create(
- project=project,
- member=user,
- role=role,
- sort_order=sort_order,
+ project=project, member=user, role=role, sort_order=sort_order
)
# Issue Property
@@ -102,9 +79,7 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
- self.style.SUCCESS(
- f"User {user_email} added to project {project_id}"
- )
+ self.style.SUCCESS(f"User {user_email} added to project {project_id}")
)
return
except CommandError as e:
diff --git a/apiserver/plane/db/management/commands/sync_issue_description_version.py b/apiserver/plane/db/management/commands/sync_issue_description_version.py
new file mode 100644
index 0000000000..7ff2fc3914
--- /dev/null
+++ b/apiserver/plane/db/management/commands/sync_issue_description_version.py
@@ -0,0 +1,23 @@
+# Django imports
+from django.core.management.base import BaseCommand
+
+# Module imports
+from plane.bgtasks.issue_description_version_sync import (
+ schedule_issue_description_version,
+)
+
+
+class Command(BaseCommand):
+ help = "Creates IssueDescriptionVersion records for existing Issues in batches"
+
+ def handle(self, *args, **options):
+ batch_size = input("Enter the batch size: ")
+ batch_countdown = input("Enter the batch countdown: ")
+
+ schedule_issue_description_version.delay(
+ batch_size=batch_size, countdown=int(batch_countdown)
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS("Successfully created issue description version task")
+ )
diff --git a/apiserver/plane/db/management/commands/sync_issue_version.py b/apiserver/plane/db/management/commands/sync_issue_version.py
new file mode 100644
index 0000000000..2b6632f261
--- /dev/null
+++ b/apiserver/plane/db/management/commands/sync_issue_version.py
@@ -0,0 +1,19 @@
+# Django imports
+from django.core.management.base import BaseCommand
+
+# Module imports
+from plane.bgtasks.issue_version_sync import schedule_issue_version
+
+
+class Command(BaseCommand):
+ help = "Creates IssueVersion records for existing Issues in batches"
+
+ def handle(self, *args, **options):
+ batch_size = input("Enter the batch size: ")
+ batch_countdown = input("Enter the batch countdown: ")
+
+ schedule_issue_version.delay(
+ batch_size=batch_size, countdown=int(batch_countdown)
+ )
+
+ self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
diff --git a/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py
new file mode 100644
index 0000000000..d38f17c5d5
--- /dev/null
+++ b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py
@@ -0,0 +1,242 @@
+# Generated by Django 4.2.15 on 2024-11-27 09:07
+
+from django.conf import settings
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import plane.db.models.webhook
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IssueVersion",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last Modified At"
+ ),
+ ),
+ (
+ "deleted_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Deleted At"
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ db_index=True,
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ ),
+ ),
+ ("parent", models.UUIDField(blank=True, null=True)),
+ ("state", models.UUIDField(blank=True, null=True)),
+ ("estimate_point", models.UUIDField(blank=True, null=True)),
+ ("name", models.CharField(max_length=255, verbose_name="Issue Name")),
+ ("description", models.JSONField(blank=True, default=dict)),
+ ("description_html", models.TextField(blank=True, default="
")),
+ ("description_stripped", models.TextField(blank=True, null=True)),
+ ("description_binary", models.BinaryField(null=True)),
+ (
+ "priority",
+ models.CharField(
+ choices=[
+ ("urgent", "Urgent"),
+ ("high", "High"),
+ ("medium", "Medium"),
+ ("low", "Low"),
+ ("none", "None"),
+ ],
+ default="none",
+ max_length=30,
+ verbose_name="Issue Priority",
+ ),
+ ),
+ ("start_date", models.DateField(blank=True, null=True)),
+ ("target_date", models.DateField(blank=True, null=True)),
+ (
+ "sequence_id",
+ models.IntegerField(default=1, verbose_name="Issue Sequence ID"),
+ ),
+ ("sort_order", models.FloatField(default=65535)),
+ ("completed_at", models.DateTimeField(null=True)),
+ ("archived_at", models.DateField(null=True)),
+ ("is_draft", models.BooleanField(default=False)),
+ (
+ "external_source",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ (
+ "external_id",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ("type", models.UUIDField(blank=True, null=True)),
+ (
+ "last_saved_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("owned_by", models.UUIDField()),
+ (
+ "assignees",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.UUIDField(),
+ blank=True,
+ default=list,
+ size=None,
+ ),
+ ),
+ (
+ "labels",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.UUIDField(),
+ blank=True,
+ default=list,
+ size=None,
+ ),
+ ),
+ ("cycle", models.UUIDField(blank=True, null=True)),
+ (
+ "modules",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.UUIDField(),
+ blank=True,
+ default=list,
+ size=None,
+ ),
+ ),
+ ("properties", models.JSONField(default=dict)),
+ ("meta", models.JSONField(default=dict)),
+ (
+ "created_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="%(class)s_created_by",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Created By",
+ ),
+ ),
+ (
+ "issue",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="versions",
+ to="db.issue",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_%(class)s",
+ 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="workspace_%(class)s",
+ to="db.workspace",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Issue Version",
+ "verbose_name_plural": "Issue Versions",
+ "db_table": "issue_versions",
+ "ordering": ("-created_at",),
+ },
+ ),
+ migrations.AlterUniqueTogether(
+ name="teampage",
+ unique_together=None,
+ ),
+ migrations.RemoveField(
+ model_name="teampage",
+ name="created_by",
+ ),
+ migrations.RemoveField(
+ model_name="teampage",
+ name="page",
+ ),
+ migrations.RemoveField(
+ model_name="teampage",
+ name="team",
+ ),
+ migrations.RemoveField(
+ model_name="teampage",
+ name="updated_by",
+ ),
+ migrations.RemoveField(
+ model_name="teampage",
+ name="workspace",
+ ),
+ migrations.RemoveField(
+ model_name="page",
+ name="teams",
+ ),
+ migrations.RemoveField(
+ model_name="team",
+ name="members",
+ ),
+ migrations.AddField(
+ model_name="fileasset",
+ name="entity_identifier",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name="webhook",
+ name="is_internal",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name="fileasset",
+ name="entity_type",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="webhook",
+ name="url",
+ field=models.URLField(
+ max_length=1024,
+ validators=[
+ plane.db.models.webhook.validate_schema,
+ plane.db.models.webhook.validate_domain,
+ ],
+ ),
+ ),
+ migrations.DeleteModel(
+ name="TeamMember",
+ ),
+ migrations.DeleteModel(
+ name="TeamPage",
+ ),
+ ]
diff --git a/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py b/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py
new file mode 100644
index 0000000000..086f52316e
--- /dev/null
+++ b/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py
@@ -0,0 +1,117 @@
+# Generated by Django 4.2.17 on 2024-12-13 10:09
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import plane.db.models.user
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0086_issueversion_alter_teampage_unique_together_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='issueversion',
+ name='description',
+ ),
+ migrations.RemoveField(
+ model_name='issueversion',
+ name='description_binary',
+ ),
+ migrations.RemoveField(
+ model_name='issueversion',
+ name='description_html',
+ ),
+ migrations.RemoveField(
+ model_name='issueversion',
+ name='description_stripped',
+ ),
+ migrations.AddField(
+ model_name='issueversion',
+ name='activity',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='is_mobile_onboarded',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='mobile_onboarding_step',
+ field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='mobile_timezone_auto_set',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='language',
+ field=models.CharField(default='en', max_length=255),
+ ),
+ migrations.AlterField(
+ model_name='issueversion',
+ name='owned_by',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.CreateModel(
+ name='Sticky',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('name', models.TextField()),
+ ('description', models.JSONField(blank=True, default=dict)),
+ ('description_html', models.TextField(blank=True, default='
')),
+ ('description_stripped', models.TextField(blank=True, null=True)),
+ ('description_binary', models.BinaryField(null=True)),
+ ('logo_props', models.JSONField(default=dict)),
+ ('color', models.CharField(blank=True, max_length=255, null=True)),
+ ('background_color', models.CharField(blank=True, max_length=255, null=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')),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)),
+ ('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='stickies', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Sticky',
+ 'verbose_name_plural': 'Stickies',
+ 'db_table': 'stickies',
+ 'ordering': ('-created_at',),
+ },
+ ),
+ migrations.CreateModel(
+ name='IssueDescriptionVersion',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('description_binary', models.BinaryField(null=True)),
+ ('description_html', models.TextField(blank=True, default='
')),
+ ('description_stripped', models.TextField(blank=True, null=True)),
+ ('description_json', models.JSONField(blank=True, default=dict)),
+ ('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
+ ('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')),
+ ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
+ ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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='workspace_%(class)s', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Issue Description Version',
+ 'verbose_name_plural': 'Issue Description Versions',
+ 'db_table': 'issue_description_versions',
+ },
+ ),
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index d00a2fa07c..1cbd627616 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -41,6 +41,8 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
+ IssueVersion,
+ IssueDescriptionVersion,
)
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
@@ -53,7 +55,6 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
-from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -61,8 +62,6 @@ from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
- Team,
- TeamMember,
Workspace,
WorkspaceBaseModel,
WorkspaceMember,
@@ -71,24 +70,6 @@ from .workspace import (
WorkspaceUserProperties,
)
-from .importer import Importer
-
-from .page import Page, PageLog, PageLabel
-
-from .estimate import Estimate, EstimatePoint
-
-from .intake import Intake, IntakeIssue
-
-from .analytic import AnalyticView
-
-from .notification import Notification, UserNotificationPreference, EmailNotificationLog
-
-from .exporter import ExporterHistory
-
-from .webhook import Webhook, WebhookLog
-
-from .dashboard import Dashboard, DashboardWidget, Widget
-
from .favorite import UserFavorite
from .issue_type import IssueType
@@ -98,3 +79,5 @@ from .recent_visit import UserRecentVisit
from .label import Label
from .device import Device, DeviceSession
+
+from .sticky import Sticky
diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py
index d7a3800031..9973d122f5 100644
--- a/apiserver/plane/db/models/asset.py
+++ b/apiserver/plane/db/models/asset.py
@@ -61,9 +61,8 @@ class FileAsset(BaseModel):
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
- entity_type = models.CharField(
- max_length=255, choices=EntityTypeContext.choices, null=True, blank=True
- )
+ entity_type = models.CharField(max_length=255, null=True, blank=True)
+ entity_identifier = models.CharField(max_length=255, null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index e37c0e7c13..ca7347ad79 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -9,11 +9,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
+from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
-
+from plane.utils.exception_logger import log_exception
+from .base import BaseModel
from .project import ProjectBaseModel
@@ -656,3 +658,165 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
+
+
+class IssueVersion(ProjectBaseModel):
+ PRIORITY_CHOICES = (
+ ("urgent", "Urgent"),
+ ("high", "High"),
+ ("medium", "Medium"),
+ ("low", "Low"),
+ ("none", "None"),
+ )
+
+ parent = models.UUIDField(blank=True, null=True)
+ state = models.UUIDField(blank=True, null=True)
+ estimate_point = models.UUIDField(blank=True, null=True)
+ name = models.CharField(max_length=255, verbose_name="Issue Name")
+ priority = models.CharField(
+ max_length=30,
+ choices=PRIORITY_CHOICES,
+ verbose_name="Issue Priority",
+ default="none",
+ )
+ start_date = models.DateField(null=True, blank=True)
+ target_date = models.DateField(null=True, blank=True)
+ assignees = ArrayField(models.UUIDField(), blank=True, default=list)
+ sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
+ labels = ArrayField(models.UUIDField(), blank=True, default=list)
+ sort_order = models.FloatField(default=65535)
+ completed_at = models.DateTimeField(null=True)
+ archived_at = models.DateField(null=True)
+ is_draft = models.BooleanField(default=False)
+ external_source = models.CharField(max_length=255, null=True, blank=True)
+ external_id = models.CharField(max_length=255, blank=True, null=True)
+ type = models.UUIDField(blank=True, null=True)
+ cycle = models.UUIDField(null=True, blank=True)
+ modules = ArrayField(models.UUIDField(), blank=True, default=list)
+ properties = models.JSONField(default=dict) # issue properties
+ meta = models.JSONField(default=dict) # issue meta
+ last_saved_at = models.DateTimeField(default=timezone.now)
+
+ issue = models.ForeignKey(
+ "db.Issue", on_delete=models.CASCADE, related_name="versions"
+ )
+ activity = models.ForeignKey(
+ "db.IssueActivity",
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name="versions",
+ )
+ owned_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="issue_versions",
+ )
+
+ class Meta:
+ verbose_name = "Issue Version"
+ verbose_name_plural = "Issue Versions"
+ db_table = "issue_versions"
+ ordering = ("-created_at",)
+
+ def __str__(self):
+ return f"{self.name} <{self.project.name}>"
+
+ @classmethod
+ def log_issue_version(cls, issue, user):
+ try:
+ """
+ Log the issue version
+ """
+
+ Module = apps.get_model("db.Module")
+ CycleIssue = apps.get_model("db.CycleIssue")
+ IssueAssignee = apps.get_model("db.IssueAssignee")
+ IssueLabel = apps.get_model("db.IssueLabel")
+
+ cycle_issue = CycleIssue.objects.filter(issue=issue).first()
+
+ cls.objects.create(
+ issue=issue,
+ parent=issue.parent_id,
+ state=issue.state_id,
+ estimate_point=issue.estimate_point_id,
+ name=issue.name,
+ priority=issue.priority,
+ start_date=issue.start_date,
+ target_date=issue.target_date,
+ assignees=list(
+ IssueAssignee.objects.filter(issue=issue).values_list(
+ "assignee_id", flat=True
+ )
+ ),
+ sequence_id=issue.sequence_id,
+ labels=list(
+ IssueLabel.objects.filter(issue=issue).values_list(
+ "label_id", flat=True
+ )
+ ),
+ sort_order=issue.sort_order,
+ completed_at=issue.completed_at,
+ archived_at=issue.archived_at,
+ is_draft=issue.is_draft,
+ external_source=issue.external_source,
+ external_id=issue.external_id,
+ type=issue.type_id,
+ cycle=cycle_issue.cycle_id if cycle_issue else None,
+ modules=list(
+ Module.objects.filter(issue=issue).values_list("id", flat=True)
+ ),
+ properties={},
+ meta={},
+ last_saved_at=timezone.now(),
+ owned_by=user,
+ )
+ return True
+ except Exception as e:
+ log_exception(e)
+ return False
+
+
+class IssueDescriptionVersion(ProjectBaseModel):
+ issue = models.ForeignKey(
+ "db.Issue", on_delete=models.CASCADE, related_name="description_versions"
+ )
+ description_binary = models.BinaryField(null=True)
+ description_html = models.TextField(blank=True, default="
")
+ description_stripped = models.TextField(blank=True, null=True)
+ description_json = models.JSONField(default=dict, blank=True)
+ last_saved_at = models.DateTimeField(default=timezone.now)
+ owned_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="issue_description_versions",
+ )
+
+ class Meta:
+ verbose_name = "Issue Description Version"
+ verbose_name_plural = "Issue Description Versions"
+ db_table = "issue_description_versions"
+
+ @classmethod
+ def log_issue_description_version(cls, issue, user):
+ try:
+ """
+ Log the issue description version
+ """
+ cls.objects.create(
+ workspace_id=issue.workspace_id,
+ project_id=issue.project_id,
+ created_by_id=issue.created_by_id,
+ updated_by_id=issue.updated_by_id,
+ owned_by_id=user,
+ last_saved_at=timezone.now(),
+ issue_id=issue.id,
+ description_binary=issue.description_binary,
+ description_html=issue.description_html,
+ description_stripped=issue.description_stripped,
+ description_json=issue.description,
+ )
+ return True
+ except Exception as e:
+ log_exception(e)
+ return False
diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py
index 91fd6ac442..81e2b15a0f 100644
--- a/apiserver/plane/db/models/page.py
+++ b/apiserver/plane/db/models/page.py
@@ -50,9 +50,6 @@ class Page(BaseModel):
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"
@@ -160,32 +157,6 @@ class ProjectPage(BaseModel):
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", "deleted_at"]
- constraints = [
- models.UniqueConstraint(
- fields=["team", "page"],
- condition=models.Q(deleted_at__isnull=True),
- name="team_page_unique_team_page_when_deleted_at_null",
- )
- ]
- verbose_name = "Team Page"
- verbose_name_plural = "Team Pages"
- db_table = "team_pages"
- ordering = ("-created_at",)
-
-
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py
new file mode 100644
index 0000000000..5f1c62660b
--- /dev/null
+++ b/apiserver/plane/db/models/sticky.py
@@ -0,0 +1,32 @@
+# Django imports
+from django.conf import settings
+from django.db import models
+
+# Module imports
+from .base import BaseModel
+
+
+class Sticky(BaseModel):
+ name = models.TextField()
+
+ description = models.JSONField(blank=True, default=dict)
+ description_html = models.TextField(blank=True, default="
")
+ description_stripped = models.TextField(blank=True, null=True)
+ description_binary = models.BinaryField(null=True)
+
+ logo_props = models.JSONField(default=dict)
+ color = models.CharField(max_length=255, blank=True, null=True)
+ background_color = models.CharField(max_length=255, blank=True, null=True)
+
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="stickies"
+ )
+ owner = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
+ )
+
+ class Meta:
+ verbose_name = "Sticky"
+ verbose_name_plural = "Stickies"
+ db_table = "stickies"
+ ordering = ("-created_at",)
diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py
index 34a86a2519..8a34e4d790 100644
--- a/apiserver/plane/db/models/user.py
+++ b/apiserver/plane/db/models/user.py
@@ -26,6 +26,14 @@ def get_default_onboarding():
}
+def get_mobile_default_onboarding():
+ return {
+ "profile_complete": False,
+ "workspace_create": False,
+ "workspace_join": False,
+ }
+
+
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -178,6 +186,12 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
+ # mobile
+ is_mobile_onboarded = models.BooleanField(default=False)
+ mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
+ mobile_timezone_auto_set = models.BooleanField(default=False)
+ # language
+ language = models.CharField(max_length=255, default="en")
class Meta:
verbose_name = "Profile"
diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py
index be2a5e9a30..ec8fcda3af 100644
--- a/apiserver/plane/db/models/webhook.py
+++ b/apiserver/plane/db/models/webhook.py
@@ -31,7 +31,9 @@ class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
)
- url = models.URLField(validators=[validate_schema, validate_domain])
+ url = models.URLField(
+ validators=[validate_schema, validate_domain], max_length=1024
+ )
is_active = models.BooleanField(default=True)
secret_key = models.CharField(max_length=255, default=generate_token)
project = models.BooleanField(default=False)
@@ -39,6 +41,7 @@ class Webhook(BaseModel):
module = models.BooleanField(default=False)
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
+ is_internal = models.BooleanField(default=False)
def __str__(self):
return f"{self.workspace.slug} {self.url}"
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index 93f8a24e0e..f8082e492b 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -239,13 +239,6 @@ class WorkspaceMemberInvite(BaseModel):
class Team(BaseModel):
name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True)
- members = models.ManyToManyField(
- settings.AUTH_USER_MODEL,
- blank=True,
- related_name="members",
- through="TeamMember",
- through_fields=("team", "member"),
- )
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
)
@@ -270,33 +263,6 @@ class Team(BaseModel):
ordering = ("-created_at",)
-class TeamMember(BaseModel):
- workspace = models.ForeignKey(
- Workspace, on_delete=models.CASCADE, related_name="team_member"
- )
- team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member")
- member = models.ForeignKey(
- settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member"
- )
-
- def __str__(self):
- return self.team.name
-
- class Meta:
- unique_together = ["team", "member", "deleted_at"]
- constraints = [
- models.UniqueConstraint(
- fields=["team", "member"],
- condition=models.Q(deleted_at__isnull=True),
- name="team_member_unique_team_member_when_deleted_at_null",
- )
- ]
- verbose_name = "Team Member"
- verbose_name_plural = "Team Members"
- db_table = "team_members"
- ordering = ("-created_at",)
-
-
class WorkspaceTheme(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py
index 681dbeb5ca..6e0a5941c4 100644
--- a/apiserver/plane/license/api/serializers/__init__.py
+++ b/apiserver/plane/license/api/serializers/__init__.py
@@ -2,3 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
+from .workspace import WorkspaceSerializer
diff --git a/apiserver/plane/license/api/serializers/user.py b/apiserver/plane/license/api/serializers/user.py
new file mode 100644
index 0000000000..c53b4a4848
--- /dev/null
+++ b/apiserver/plane/license/api/serializers/user.py
@@ -0,0 +1,8 @@
+from .base import BaseSerializer
+from plane.db.models import User
+
+
+class UserLiteSerializer(BaseSerializer):
+ class Meta:
+ model = User
+ fields = ["id", "email", "first_name", "last_name"]
diff --git a/apiserver/plane/license/api/serializers/workspace.py b/apiserver/plane/license/api/serializers/workspace.py
new file mode 100644
index 0000000000..75dd938e45
--- /dev/null
+++ b/apiserver/plane/license/api/serializers/workspace.py
@@ -0,0 +1,37 @@
+# Third Party Imports
+from rest_framework import serializers
+
+# Module imports
+from .base import BaseSerializer
+from .user import UserLiteSerializer
+from plane.db.models import Workspace
+from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
+
+
+class WorkspaceSerializer(BaseSerializer):
+ owner = UserLiteSerializer(read_only=True)
+ logo_url = serializers.CharField(read_only=True)
+ total_projects = serializers.IntegerField(read_only=True)
+ total_members = serializers.IntegerField(read_only=True)
+
+ def validate_slug(self, value):
+ # Check if the slug is restricted
+ if value in RESTRICTED_WORKSPACE_SLUGS:
+ raise serializers.ValidationError("Slug is not valid")
+ # Check uniqueness case-insensitively
+ if Workspace.objects.filter(slug__iexact=value).exists():
+ raise serializers.ValidationError("Slug is already in use")
+ return value
+
+ class Meta:
+ model = Workspace
+ fields = "__all__"
+ read_only_fields = [
+ "id",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "owner",
+ "logo_url",
+ ]
diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py
index 9427ed15e5..a2ef90facb 100644
--- a/apiserver/plane/license/api/views/__init__.py
+++ b/apiserver/plane/license/api/views/__init__.py
@@ -13,4 +13,8 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
-from .changelog import ChangeLogEndpoint
+
+from .workspace import (
+ InstanceWorkSpaceAvailabilityCheckEndpoint,
+ InstanceWorkSpaceEndpoint,
+)
diff --git a/apiserver/plane/license/api/views/changelog.py b/apiserver/plane/license/api/views/changelog.py
deleted file mode 100644
index 52583a35fc..0000000000
--- a/apiserver/plane/license/api/views/changelog.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# Python imports
-import requests
-
-# Django imports
-from django.conf import settings
-
-# Third party imports
-from rest_framework.response import Response
-from rest_framework import status
-from rest_framework.permissions import AllowAny
-
-# plane imports
-from .base import BaseAPIView
-
-
-class ChangeLogEndpoint(BaseAPIView):
- permission_classes = [AllowAny]
-
- def fetch_change_logs(self):
- response = requests.get(settings.INSTANCE_CHANGELOG_URL)
- response.raise_for_status()
- return response.json()
-
- def get(self, request):
- # Fetch the changelog
- if settings.INSTANCE_CHANGELOG_URL:
- data = self.fetch_change_logs()
- return Response(data, status=status.HTTP_200_OK)
- else:
- return Response(
- {"error": "could not fetch changelog please try again later"},
- status=status.HTTP_400_BAD_REQUEST,
- )
diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py
index 883fb7c972..0e2b64fc9b 100644
--- a/apiserver/plane/license/api/views/instance.py
+++ b/apiserver/plane/license/api/views/instance.py
@@ -45,6 +45,7 @@ class InstanceEndpoint(BaseAPIView):
# Get all the configuration
(
ENABLE_SIGNUP,
+ DISABLE_WORKSPACE_CREATION,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
@@ -65,6 +66,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
+ {
+ "key": "DISABLE_WORKSPACE_CREATION",
+ "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
+ },
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@@ -125,6 +130,7 @@ class InstanceEndpoint(BaseAPIView):
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
+ data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py
new file mode 100644
index 0000000000..607016cc3a
--- /dev/null
+++ b/apiserver/plane/license/api/views/workspace.py
@@ -0,0 +1,113 @@
+# Third party imports
+from rest_framework.response import Response
+from rest_framework import status
+from django.db import IntegrityError
+from django.db.models import OuterRef, Func, F
+
+# Module imports
+from plane.app.views.base import BaseAPIView
+from plane.license.api.permissions import InstanceAdminPermission
+from plane.db.models import Workspace, WorkspaceMember, Project
+from plane.license.api.serializers import WorkspaceSerializer
+from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
+
+
+class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
+ permission_classes = [InstanceAdminPermission]
+
+ def get(self, request):
+ slug = request.GET.get("slug", False)
+
+ if not slug or slug == "":
+ return Response(
+ {"error": "Workspace Slug is required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ workspace = (
+ Workspace.objects.filter(slug__iexact=slug).exists()
+ or slug in RESTRICTED_WORKSPACE_SLUGS
+ )
+ return Response({"status": not workspace}, status=status.HTTP_200_OK)
+
+
+class InstanceWorkSpaceEndpoint(BaseAPIView):
+ model = Workspace
+ serializer_class = WorkspaceSerializer
+ permission_classes = [InstanceAdminPermission]
+
+ def get(self, request):
+ project_count = (
+ Project.objects.filter(workspace_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+
+ member_count = (
+ WorkspaceMember.objects.filter(
+ workspace=OuterRef("id"), member__is_bot=False, is_active=True
+ )
+ .select_related("owner")
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+
+ workspaces = Workspace.objects.annotate(
+ total_projects=project_count, total_members=member_count
+ )
+
+ # Add search functionality
+ search = request.query_params.get("search", None)
+ if search:
+ workspaces = workspaces.filter(name__icontains=search)
+
+ return self.paginate(
+ request=request,
+ queryset=workspaces,
+ on_results=lambda results: WorkspaceSerializer(results, many=True).data,
+ max_per_page=10,
+ default_per_page=10,
+ )
+
+ def post(self, request):
+ try:
+ serializer = WorkspaceSerializer(data=request.data)
+
+ slug = request.data.get("slug", False)
+ name = request.data.get("name", False)
+
+ if not name or not slug:
+ return Response(
+ {"error": "Both name and slug are required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if len(name) > 80 or len(slug) > 48:
+ return Response(
+ {"error": "The maximum length for name is 80 and for slug is 48"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if serializer.is_valid(raise_exception=True):
+ serializer.save(owner=request.user)
+ # Create Workspace member
+ _ = WorkspaceMember.objects.create(
+ workspace_id=serializer.data["id"],
+ member=request.user,
+ role=20,
+ company_role=request.data.get("company_role", ""),
+ )
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(
+ [serializer.errors[error][0] for error in serializer.errors],
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ except IntegrityError as e:
+ if "already exists" in str(e):
+ return Response(
+ {"slug": "The workspace with the slug already exists"},
+ status=status.HTTP_410_GONE,
+ )
diff --git a/apiserver/plane/license/bgtasks/tracer.py b/apiserver/plane/license/bgtasks/tracer.py
index 60588f5b7b..47e74c83ab 100644
--- a/apiserver/plane/license/bgtasks/tracer.py
+++ b/apiserver/plane/license/bgtasks/tracer.py
@@ -16,76 +16,55 @@ from plane.db.models import (
Page,
WorkspaceMember,
)
+from plane.utils.telemetry import init_tracer, shutdown_tracer
@shared_task
def instance_traces():
- # Get the tracer
- tracer = trace.get_tracer(__name__)
+ try:
+ init_tracer()
+ # Check if the instance is registered
+ instance = Instance.objects.first()
- # Check if the instance is registered
- instance = Instance.objects.first()
+ # If instance is None then return
+ if instance is None:
+ return
- # If instance is None then return
- if instance is None:
- return
+ if instance.is_telemetry_enabled:
+ # Get the tracer
+ tracer = trace.get_tracer(__name__)
+ # Instance details
+ with tracer.start_as_current_span("instance_details") as span:
+ # Count of all models
+ workspace_count = Workspace.objects.count()
+ user_count = User.objects.count()
+ project_count = Project.objects.count()
+ issue_count = Issue.objects.count()
+ module_count = Module.objects.count()
+ cycle_count = Cycle.objects.count()
+ cycle_issue_count = CycleIssue.objects.count()
+ module_issue_count = ModuleIssue.objects.count()
+ page_count = Page.objects.count()
- if instance.is_telemetry_enabled:
- # Instance details
- with tracer.start_as_current_span("instance_details") as span:
- # Count of all models
- workspace_count = Workspace.objects.count()
- user_count = User.objects.count()
- project_count = Project.objects.count()
- issue_count = Issue.objects.count()
- module_count = Module.objects.count()
- cycle_count = Cycle.objects.count()
- cycle_issue_count = CycleIssue.objects.count()
- module_issue_count = ModuleIssue.objects.count()
- page_count = Page.objects.count()
-
- # Set span attributes
- span.set_attribute("instance_id", instance.instance_id)
- span.set_attribute("instance_name", instance.instance_name)
- span.set_attribute("current_version", instance.current_version)
- span.set_attribute("latest_version", instance.latest_version)
- span.set_attribute("is_telemetry_enabled", instance.is_telemetry_enabled)
- span.set_attribute("is_support_required", instance.is_support_required)
- span.set_attribute("is_setup_done", instance.is_setup_done)
- span.set_attribute(
- "is_signup_screen_visited", instance.is_signup_screen_visited
- )
- span.set_attribute("is_verified", instance.is_verified)
- span.set_attribute("edition", instance.edition)
- span.set_attribute("domain", instance.domain)
- span.set_attribute("is_test", instance.is_test)
- span.set_attribute("user_count", user_count)
- span.set_attribute("workspace_count", workspace_count)
- span.set_attribute("project_count", project_count)
- span.set_attribute("issue_count", issue_count)
- span.set_attribute("module_count", module_count)
- span.set_attribute("cycle_count", cycle_count)
- span.set_attribute("cycle_issue_count", cycle_issue_count)
- span.set_attribute("module_issue_count", module_issue_count)
- span.set_attribute("page_count", page_count)
-
- # Workspace details
- for workspace in Workspace.objects.all():
- # Count of all models
- project_count = Project.objects.filter(workspace=workspace).count()
- issue_count = Issue.objects.filter(workspace=workspace).count()
- module_count = Module.objects.filter(workspace=workspace).count()
- cycle_count = Cycle.objects.filter(workspace=workspace).count()
- cycle_issue_count = CycleIssue.objects.filter(workspace=workspace).count()
- module_issue_count = ModuleIssue.objects.filter(workspace=workspace).count()
- page_count = Page.objects.filter(workspace=workspace).count()
- member_count = WorkspaceMember.objects.filter(workspace=workspace).count()
-
- # Set span attributes
- with tracer.start_as_current_span("workspace_details") as span:
+ # Set span attributes
span.set_attribute("instance_id", instance.instance_id)
- span.set_attribute("workspace_id", str(workspace.id))
- span.set_attribute("workspace_slug", workspace.slug)
+ span.set_attribute("instance_name", instance.instance_name)
+ span.set_attribute("current_version", instance.current_version)
+ span.set_attribute("latest_version", instance.latest_version)
+ span.set_attribute(
+ "is_telemetry_enabled", instance.is_telemetry_enabled
+ )
+ span.set_attribute("is_support_required", instance.is_support_required)
+ span.set_attribute("is_setup_done", instance.is_setup_done)
+ span.set_attribute(
+ "is_signup_screen_visited", instance.is_signup_screen_visited
+ )
+ span.set_attribute("is_verified", instance.is_verified)
+ span.set_attribute("edition", instance.edition)
+ span.set_attribute("domain", instance.domain)
+ span.set_attribute("is_test", instance.is_test)
+ span.set_attribute("user_count", user_count)
+ span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
@@ -93,6 +72,40 @@ def instance_traces():
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
- span.set_attribute("member_count", member_count)
- return
+ # Workspace details
+ for workspace in Workspace.objects.all():
+ # Count of all models
+ project_count = Project.objects.filter(workspace=workspace).count()
+ issue_count = Issue.objects.filter(workspace=workspace).count()
+ module_count = Module.objects.filter(workspace=workspace).count()
+ cycle_count = Cycle.objects.filter(workspace=workspace).count()
+ cycle_issue_count = CycleIssue.objects.filter(
+ workspace=workspace
+ ).count()
+ module_issue_count = ModuleIssue.objects.filter(
+ workspace=workspace
+ ).count()
+ page_count = Page.objects.filter(workspace=workspace).count()
+ member_count = WorkspaceMember.objects.filter(
+ workspace=workspace
+ ).count()
+
+ # Set span attributes
+ with tracer.start_as_current_span("workspace_details") as span:
+ span.set_attribute("instance_id", instance.instance_id)
+ span.set_attribute("workspace_id", str(workspace.id))
+ span.set_attribute("workspace_slug", workspace.slug)
+ span.set_attribute("project_count", project_count)
+ span.set_attribute("issue_count", issue_count)
+ span.set_attribute("module_count", module_count)
+ span.set_attribute("cycle_count", cycle_count)
+ span.set_attribute("cycle_issue_count", cycle_issue_count)
+ span.set_attribute("module_issue_count", module_issue_count)
+ span.set_attribute("page_count", page_count)
+ span.set_attribute("member_count", member_count)
+
+ return
+ finally:
+ # Shutdown the tracer
+ shutdown_tracer()
diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py
index a1c27851e3..548c9c77ed 100644
--- a/apiserver/plane/license/management/commands/configure_instance.py
+++ b/apiserver/plane/license/management/commands/configure_instance.py
@@ -29,6 +29,12 @@ class Command(BaseCommand):
"category": "AUTHENTICATION",
"is_encrypted": False,
},
+ {
+ "key": "DISABLE_WORKSPACE_CREATION",
+ "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
+ "category": "WORKSPACE_MANAGEMENT",
+ "is_encrypted": False,
+ },
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py
index 50e6d0de89..9c3adbf98a 100644
--- a/apiserver/plane/license/urls.py
+++ b/apiserver/plane/license/urls.py
@@ -11,12 +11,12 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
- ChangeLogEndpoint,
+ InstanceWorkSpaceAvailabilityCheckEndpoint,
+ InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
- path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -55,4 +55,10 @@ urlpatterns = [
EmailCredentialCheckEndpoint.as_view(),
name="email-credential-check",
),
+ path(
+ "workspace-slug-check/",
+ InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
+ name="instance-workspace-availability",
+ ),
+ path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py
index 864010d3c7..db9a244537 100644
--- a/apiserver/plane/settings/common.py
+++ b/apiserver/plane/settings/common.py
@@ -16,14 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
-# OpenTelemetry
-from opentelemetry import trace
-from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
-from opentelemetry.sdk.trace import TracerProvider
-from opentelemetry.sdk.trace.export import BatchSpanProcessor
-from opentelemetry.sdk.resources import Resource
-from opentelemetry.instrumentation.django import DjangoInstrumentor
-
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -33,19 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
-# Configure the tracer provider
-service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
-resource = Resource.create({"service.name": service_name})
-trace.set_tracer_provider(TracerProvider(resource=resource))
-# Configure the OTLP exporter
-otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
-otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
-span_processor = BatchSpanProcessor(otlp_exporter)
-trace.get_tracer_provider().add_span_processor(span_processor)
-# Initialize Django instrumentation
-DjangoInstrumentor().instrument()
-
-
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
@@ -283,6 +262,9 @@ CELERY_IMPORTS = (
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
+ # issue version tasks
+ "plane.bgtasks.issue_version_sync",
+ "plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py
index 7676c95992..068b8c5c17 100644
--- a/apiserver/plane/space/urls/project.py
+++ b/apiserver/plane/space/urls/project.py
@@ -10,9 +10,15 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
+ ProjectMetaDataEndpoint,
)
urlpatterns = [
+ path(
+ "anchor//meta/",
+ ProjectMetaDataEndpoint.as_view(),
+ name="project-meta",
+ ),
path(
"anchor//settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py
index 250b54e891..2740588422 100644
--- a/apiserver/plane/space/utils/grouper.py
+++ b/apiserver/plane/space/utils/grouper.py
@@ -91,6 +91,7 @@ def issue_on_results(issues, group_by, sub_group_by):
Case(
When(
votes__isnull=False,
+ votes__deleted_at__isnull=True,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
@@ -117,13 +118,14 @@ def issue_on_results(issues, group_by, sub_group_by):
default=None,
output_field=JSONField(),
),
- filter=Q(votes__isnull=False),
+ filter=Q(votes__isnull=False,votes__deleted_at__isnull=True),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
+ issue_reactions__deleted_at__isnull=True,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
@@ -150,7 +152,7 @@ def issue_on_results(issues, group_by, sub_group_by):
default=None,
output_field=JSONField(),
),
- filter=Q(issue_reactions__isnull=False),
+ filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True),
distinct=True,
),
).values(*required_fields, "vote_items", "reaction_items")
diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py
index afdc1d3374..22acfd15bd 100644
--- a/apiserver/plane/space/views/__init__.py
+++ b/apiserver/plane/space/views/__init__.py
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
+
+from .meta import ProjectMetaDataEndpoint
diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py
index 2c67220384..3e1d4d6f78 100644
--- a/apiserver/plane/space/views/asset.py
+++ b/apiserver/plane/space/views/asset.py
@@ -86,7 +86,13 @@ class EntityAssetEndpoint(BaseAPIView):
)
# Check if the file type is allowed
- allowed_types = ["image/jpeg", "image/png", "image/webp"]
+ allowed_types = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/jpg",
+ "image/gif",
+ ]
if type not in allowed_types:
return Response(
{
diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py
index a1ab332f91..699253ae52 100644
--- a/apiserver/plane/space/views/issue.py
+++ b/apiserver/plane/space/views/issue.py
@@ -701,6 +701,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
Case(
When(
votes__isnull=False,
+ votes__deleted_at__isnull=True,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
@@ -732,7 +733,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
output_field=JSONField(),
),
filter=Case(
- When(votes__isnull=False, then=True),
+ When(
+ votes__isnull=False,
+ votes__deleted_at__isnull=True,
+ then=True,
+ ),
default=False,
output_field=JSONField(),
),
@@ -742,6 +747,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
Case(
When(
issue_reactions__isnull=False,
+ issue_reactions__deleted_at__isnull=True,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
@@ -775,7 +781,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
output_field=JSONField(),
),
filter=Case(
- When(issue_reactions__isnull=False, then=True),
+ When(
+ issue_reactions__isnull=False,
+ issue_reactions__deleted_at__isnull=True,
+ then=True,
+ ),
default=False,
output_field=JSONField(),
),
diff --git a/apiserver/plane/space/views/meta.py b/apiserver/plane/space/views/meta.py
new file mode 100644
index 0000000000..fa44135996
--- /dev/null
+++ b/apiserver/plane/space/views/meta.py
@@ -0,0 +1,34 @@
+# third party
+from rest_framework.permissions import AllowAny
+from rest_framework import status
+from rest_framework.response import Response
+
+from plane.db.models import DeployBoard, Project
+
+from .base import BaseAPIView
+from plane.space.serializer.project import ProjectLiteSerializer
+
+
+class ProjectMetaDataEndpoint(BaseAPIView):
+ permission_classes = [AllowAny]
+
+ def get(self, request, anchor):
+ try:
+ deploy_board = DeployBoard.objects.filter(
+ anchor=anchor, entity_name="project"
+ ).first()
+ except DeployBoard.DoesNotExist:
+ return Response(
+ {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
+ )
+
+ try:
+ project_id = deploy_board.entity_identifier
+ project = Project.objects.get(id=project_id)
+ except Project.DoesNotExist:
+ return Response(
+ {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
+ )
+
+ serializer = ProjectLiteSerializer(project)
+ return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/utils/telemetry.py b/apiserver/plane/utils/telemetry.py
new file mode 100644
index 0000000000..bec3d240dd
--- /dev/null
+++ b/apiserver/plane/utils/telemetry.py
@@ -0,0 +1,58 @@
+# Python imports
+import os
+import atexit
+
+# Third party imports
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.instrumentation.django import DjangoInstrumentor
+
+# Global variable to track initialization
+_TRACER_PROVIDER = None
+
+
+def init_tracer():
+ """Initialize OpenTelemetry with proper shutdown handling"""
+ global _TRACER_PROVIDER
+
+ # If already initialized, return existing provider
+ if _TRACER_PROVIDER is not None:
+ return _TRACER_PROVIDER
+
+ # Configure the tracer provider
+ service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
+ resource = Resource.create({"service.name": service_name})
+ tracer_provider = TracerProvider(resource=resource)
+
+ # Set as global tracer provider
+ trace.set_tracer_provider(tracer_provider)
+
+ # Configure the OTLP exporter
+ otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
+ otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
+ span_processor = BatchSpanProcessor(otlp_exporter)
+ tracer_provider.add_span_processor(span_processor)
+
+ # Initialize Django instrumentation
+ DjangoInstrumentor().instrument()
+
+ # Store provider globally
+ _TRACER_PROVIDER = tracer_provider
+
+ # Register shutdown handler
+ atexit.register(shutdown_tracer)
+
+ return tracer_provider
+
+
+def shutdown_tracer():
+ """Shutdown OpenTelemetry tracers and processors"""
+ global _TRACER_PROVIDER
+
+ if _TRACER_PROVIDER is not None:
+ if hasattr(_TRACER_PROVIDER, "shutdown"):
+ _TRACER_PROVIDER.shutdown()
+ _TRACER_PROVIDER = None
diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py
new file mode 100644
index 0000000000..46a864b62d
--- /dev/null
+++ b/apiserver/plane/utils/timezone_converter.py
@@ -0,0 +1,100 @@
+import pytz
+from plane.db.models import Project
+from datetime import datetime, time
+from datetime import timedelta
+
+def user_timezone_converter(queryset, datetime_fields, user_timezone):
+ # Create a timezone object for the user's timezone
+ user_tz = pytz.timezone(user_timezone)
+
+ # Check if queryset is a dictionary (single item) or a list of dictionaries
+ if isinstance(queryset, dict):
+ queryset_values = [queryset]
+ else:
+ queryset_values = list(queryset)
+
+ # Iterate over the dictionaries in the list
+ for item in queryset_values:
+ # Iterate over the datetime fields
+ for field in datetime_fields:
+ # Convert the datetime field to the user's timezone
+ if field in item and item[field]:
+ item[field] = item[field].astimezone(user_tz)
+
+ # If queryset was a single item, return a single item
+ if isinstance(queryset, dict):
+ return queryset_values[0]
+ else:
+ return queryset_values
+
+
+def convert_to_utc(date, project_id, is_start_date=False):
+ """
+ Converts a start date string to the project's local timezone at 12:00 AM
+ and then converts it to UTC for storage.
+
+ Args:
+ date (str): The date string in "YYYY-MM-DD" format.
+ project_id (int): The project's ID to fetch the associated timezone.
+
+ Returns:
+ datetime: The UTC datetime.
+ """
+ # Retrieve the project's timezone using the project ID
+ project = Project.objects.get(id=project_id)
+ project_timezone = project.timezone
+ if not date or not project_timezone:
+ raise ValueError("Both date and timezone must be provided.")
+
+ # Parse the string into a date object
+ start_date = datetime.strptime(date, "%Y-%m-%d").date()
+
+ # Get the project's timezone
+ local_tz = pytz.timezone(project_timezone)
+
+ # Combine the date with 12:00 AM time
+ local_datetime = datetime.combine(start_date, time.min)
+
+ # Localize the datetime to the project's timezone
+ localized_datetime = local_tz.localize(local_datetime)
+
+ # If it's an start date, add one minute
+ if is_start_date:
+ localized_datetime += timedelta(minutes=1)
+
+ # Convert the localized datetime to UTC
+ utc_datetime = localized_datetime.astimezone(pytz.utc)
+
+ # Return the UTC datetime for storage
+ return utc_datetime
+
+
+def convert_utc_to_project_timezone(utc_datetime, project_id):
+ """
+ Converts a UTC datetime (stored in the database) to the project's local timezone.
+
+ Args:
+ utc_datetime (datetime): The UTC datetime to be converted.
+ project_id (int): The project's ID to fetch the associated timezone.
+
+ Returns:
+ datetime: The datetime in the project's local timezone.
+ """
+ # Retrieve the project's timezone using the project ID
+ project = Project.objects.get(id=project_id)
+ project_timezone = project.timezone
+ if not project_timezone:
+ raise ValueError("Project timezone must be provided.")
+
+ # Get the timezone object for the project's timezone
+ local_tz = pytz.timezone(project_timezone)
+
+ # Convert the UTC datetime to the project's local timezone
+ if utc_datetime.tzinfo is None:
+ # Localize UTC datetime if it's naive (i.e., without timezone info)
+ utc_datetime = pytz.utc.localize(utc_datetime)
+
+ # Convert to the project's local timezone
+ local_datetime = utc_datetime.astimezone(local_tz)
+
+ return local_datetime
diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py
deleted file mode 100644
index 550abfe997..0000000000
--- a/apiserver/plane/utils/user_timezone_converter.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import pytz
-
-
-def user_timezone_converter(queryset, datetime_fields, user_timezone):
- # Create a timezone object for the user's timezone
- user_tz = pytz.timezone(user_timezone)
-
- # Check if queryset is a dictionary (single item) or a list of dictionaries
- if isinstance(queryset, dict):
- queryset_values = [queryset]
- else:
- queryset_values = list(queryset)
-
- # Iterate over the dictionaries in the list
- for item in queryset_values:
- # Iterate over the datetime fields
- for field in datetime_fields:
- # Convert the datetime field to the user's timezone
- if field in item and item[field]:
- item[field] = item[field].astimezone(user_tz)
-
- # If queryset was a single item, return a single item
- if isinstance(queryset, dict):
- return queryset_values[0]
- else:
- return queryset_values
diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt
index 854ab95f90..40e90aedfc 100644
--- a/apiserver/requirements/base.txt
+++ b/apiserver/requirements/base.txt
@@ -1,7 +1,7 @@
# base requirements
# django
-Django==4.2.16
+Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
diff --git a/app.json b/app.json
index bc5789078e..600b524d2f 100644
--- a/app.json
+++ b/app.json
@@ -70,7 +70,7 @@
"value": ""
},
"GITHUB_CLIENT_SECRET": {
- "description": "Github Client Secret",
+ "description": "GitHub Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md
index d93d85ca14..ccd8bf328c 100644
--- a/deploy/selfhost/README.md
+++ b/deploy/selfhost/README.md
@@ -62,7 +62,7 @@ mkdir plane-selfhost
cd plane-selfhost
-curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
+curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
chmod +x setup.sh
```
diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml
index fe47e625f6..13cfafa223 100644
--- a/deploy/selfhost/docker-compose.yml
+++ b/deploy/selfhost/docker-compose.yml
@@ -1,54 +1,63 @@
-x-app-env: &app-env
- environment:
- - NGINX_PORT=${NGINX_PORT:-80}
- - WEB_URL=${WEB_URL:-http://localhost}
- - DEBUG=${DEBUG:-0}
- - SENTRY_DSN=${SENTRY_DSN:-""}
- - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
- # Gunicorn Workers
- - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
- #DB SETTINGS
- - PGHOST=${PGHOST:-plane-db}
- - PGDATABASE=${PGDATABASE:-plane}
- - POSTGRES_USER=${POSTGRES_USER:-plane}
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- - POSTGRES_DB=${POSTGRES_DB:-plane}
- - POSTGRES_PORT=${POSTGRES_PORT:-5432}
- - PGDATA=${PGDATA:-/var/lib/postgresql/data}
- - DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
- # REDIS SETTINGS
- - REDIS_HOST=${REDIS_HOST:-plane-redis}
- - REDIS_PORT=${REDIS_PORT:-6379}
- - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
+x-db-env: &db-env
+ PGHOST: ${PGHOST:-plane-db}
+ PGDATABASE: ${PGDATABASE:-plane}
+ POSTGRES_USER: ${POSTGRES_USER:-plane}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
+ POSTGRES_DB: ${POSTGRES_DB:-plane}
+ POSTGRES_PORT: ${POSTGRES_PORT:-5432}
+ PGDATA: ${PGDATA:-/var/lib/postgresql/data}
+
+x-redis-env: &redis-env
+ REDIS_HOST: ${REDIS_HOST:-plane-redis}
+ REDIS_PORT: ${REDIS_PORT:-6379}
+ REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
+
+x-minio-env: &minio-env
+ MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
+ MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
+
+x-aws-s3-env: &aws-s3-env
+ AWS_REGION: ${AWS_REGION:-}
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
+ AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
+ AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
+
+x-proxy-env: &proxy-env
+ NGINX_PORT: ${NGINX_PORT:-80}
+ BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
+ FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
+
+x-mq-env: &mq-env
+ # RabbitMQ Settings
+ RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
+ RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
+ RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
+ RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
+ RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
+ RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
+
+x-live-env: &live-env
+ API_BASE_URL: ${API_BASE_URL:-http://api:8000}
+
+x-app-env: &app-env
+ WEB_URL: ${WEB_URL:-http://localhost}
+ DEBUG: ${DEBUG:-0}
+ SENTRY_DSN: ${SENTRY_DSN}
+ SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
+ CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
+ GUNICORN_WORKERS: 1
+ USE_MINIO: ${USE_MINIO:-1}
+ DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
+ SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
+ ADMIN_BASE_URL: ${ADMIN_BASE_URL}
+ SPACE_BASE_URL: ${SPACE_BASE_URL}
+ APP_BASE_URL: ${APP_BASE_URL}
+ AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
- # RabbitMQ Settings
- - RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq}
- - RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
- - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane}
- - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane}
- - RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane}
- - RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane}
- - AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
- # Application secret
- - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- # DATA STORE SETTINGS
- - USE_MINIO=${USE_MINIO:-1}
- - AWS_REGION=${AWS_REGION:-}
- - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- - MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- - BUCKET_NAME=${BUCKET_NAME:-uploads}
- - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- # Live server env
- - API_BASE_URL=${API_BASE_URL:-http://api:8000}
services:
web:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -61,7 +70,6 @@ services:
- worker
space:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -75,7 +83,6 @@ services:
- web
admin:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -88,12 +95,13 @@ services:
- web
live:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node live/dist/server.js live
+ environment:
+ <<: [ *live-env ]
deploy:
replicas: ${LIVE_REPLICAS:-1}
depends_on:
@@ -101,7 +109,6 @@ services:
- web
api:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -111,14 +118,14 @@ services:
replicas: ${API_REPLICAS:-1}
volumes:
- logs_api:/code/plane/logs
+ environment:
+ <<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- plane-db
- plane-redis
- plane-mq
-
worker:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -126,6 +133,8 @@ services:
command: ./bin/docker-entrypoint-worker.sh
volumes:
- logs_worker:/code/plane/logs
+ environment:
+ <<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- api
- plane-db
@@ -133,7 +142,6 @@ services:
- plane-mq
beat-worker:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -141,6 +149,8 @@ services:
command: ./bin/docker-entrypoint-beat.sh
volumes:
- logs_beat-worker:/code/plane/logs
+ environment:
+ <<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- api
- plane-db
@@ -148,7 +158,6 @@ services:
- plane-mq
migrator:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -156,21 +165,23 @@ services:
command: ./bin/docker-entrypoint-migrator.sh
volumes:
- logs_migrator:/code/plane/logs
+ environment:
+ <<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- plane-db
- plane-redis
plane-db:
- <<: *app-env
image: postgres:15.7-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'
+ environment:
+ <<: *db-env
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
- <<: *app-env
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
@@ -178,30 +189,33 @@ services:
- redisdata:/data
plane-mq:
- <<: *app-env
image: rabbitmq:3.13.6-management-alpine
restart: always
+ environment:
+ <<: *mq-env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
plane-minio:
- <<: *app-env
image: minio/minio:latest
pull_policy: if_not_present
restart: unless-stopped
command: server /export --console-address ":9090"
+ environment:
+ <<: *minio-env
volumes:
- uploads:/export
# Comment this if you already have a reverse proxy running
proxy:
- <<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
ports:
- ${NGINX_PORT}:80
+ environment:
+ <<: *proxy-env
depends_on:
- web
- api
diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh
index 08cd4d9169..1c2208cab7 100755
--- a/deploy/selfhost/install.sh
+++ b/deploy/selfhost/install.sh
@@ -4,9 +4,12 @@ BRANCH=${BRANCH:-master}
SCRIPT_DIR=$PWD
SERVICE_FOLDER=plane-app
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
-export APP_RELEASE="stable"
+export APP_RELEASE=stable
export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
+export GH_REPO=makeplane/plane
+export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
+export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
@@ -16,13 +19,6 @@ mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
-SED_PREFIX=()
-if [ "$OS_NAME" == "Darwin" ]; then
- SED_PREFIX=("-i" "")
-else
- SED_PREFIX=("-i")
-fi
-
function print_header() {
clear
@@ -59,6 +55,17 @@ function spinner() {
printf " \b\b\b\b" >&2
}
+function checkLatestRelease(){
+ echo "Checking for the latest release..." >&2
+ local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
+ if [ -z "$latest_release" ]; then
+ echo "Failed to check for the latest release. Exiting..." >&2
+ exit 1
+ fi
+
+ echo $latest_release
+}
+
function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
@@ -130,8 +137,12 @@ function updateEnvFile() {
echo "$key=$value" >> "$file"
return
else
- # if key exists, update the value
- sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
+ if [ "$OS_NAME" == "Darwin" ]; then
+ value=$(echo "$value" | sed 's/|/\\|/g')
+ sed -i '' "s|^$key=.*|$key=$value|g" "$file"
+ else
+ sed -i "s/^$key=.*/$key=$value/g" "$file"
+ fi
fi
else
echo "File not found: $file"
@@ -182,7 +193,7 @@ function buildYourOwnImage(){
local PLANE_TEMP_CODE_DIR=~/tmp/plane
rm -rf $PLANE_TEMP_CODE_DIR
mkdir -p $PLANE_TEMP_CODE_DIR
- REPO=https://github.com/makeplane/plane.git
+ REPO=https://github.com/$GH_REPO.git
git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1
cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml"
@@ -204,6 +215,10 @@ function install() {
echo "Begin Installing Plane"
echo ""
+ if [ "$APP_RELEASE" == "stable" ]; then
+ export APP_RELEASE=$(checkLatestRelease)
+ fi
+
local build_image=$(initialize)
if [ "$build_image" == "build" ]; then
@@ -232,8 +247,49 @@ function download() {
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
fi
- curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
- curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
+ RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
+ BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
+ STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
+
+ if [ "$STATUS" -eq 200 ]; then
+ echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
+ else
+ # Fallback to download from the raw github url
+ RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
+ BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
+ STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
+
+ if [ "$STATUS" -eq 200 ]; then
+ echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
+ else
+ echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
+ echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
+ mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
+ exit 1
+ fi
+ fi
+
+ RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
+ BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
+ STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
+
+ if [ "$STATUS" -eq 200 ]; then
+ echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
+ else
+ # Fallback to download from the raw github url
+ RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
+ BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
+ STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
+
+ if [ "$STATUS" -eq 200 ]; then
+ echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
+ else
+ echo "Failed to download variables.env. HTTP Status: $STATUS"
+ echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
+ mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
+ exit 1
+ fi
+ fi
if [ -f "$DOCKER_ENV_PATH" ];
then
@@ -335,6 +391,34 @@ function restartServices() {
startServices
}
function upgrade() {
+ local latest_release=$(checkLatestRelease)
+
+ echo ""
+ echo "Current release: $APP_RELEASE"
+
+ if [ "$latest_release" == "$APP_RELEASE" ]; then
+ echo ""
+ echo "You are already using the latest release"
+ exit 0
+ fi
+
+ echo "Latest release: $latest_release"
+ echo ""
+
+ # Check for confirmation to upgrade
+ echo "Do you want to upgrade to the latest release ($latest_release)?"
+ read -p "Continue? [y/N]: " confirm
+
+ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
+ echo "Exiting..."
+ exit 0
+ fi
+
+ export APP_RELEASE=$latest_release
+
+ echo "Upgrading Plane to the latest release..."
+ echo ""
+
echo "***** STOPPING SERVICES ****"
stopServices
diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env
index b5221c71a1..78914c3afd 100644
--- a/deploy/selfhost/variables.env
+++ b/deploy/selfhost/variables.env
@@ -47,9 +47,6 @@ AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
-MINIO_ROOT_USER=access-key
-MINIO_ROOT_PASSWORD=secret-key
-BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
# Gunicorn Workers
diff --git a/live/package.json b/live/package.json
index 07d8a053fd..77cf697abe 100644
--- a/live/package.json
+++ b/live/package.json
@@ -1,16 +1,16 @@
{
"name": "live",
- "version": "0.23.1",
+ "version": "0.24.1",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
+ "dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
- "lint": "eslint . --ext .ts,.tsx",
- "dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
- "lint:errors": "eslint . --ext .ts,.tsx --quiet"
+ "lint": "eslint src --ext .ts,.tsx",
+ "lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
@@ -20,6 +20,7 @@
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
+ "@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
@@ -30,7 +31,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
- "express": "^4.20.0",
+ "express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts
index b34a8fbb22..51896c23bc 100644
--- a/live/src/core/hocuspocus-server.ts
+++ b/live/src/core/hocuspocus-server.ts
@@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
+import {
+ DocumentCollaborativeEvents,
+ TDocumentEventsServer,
+} from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
+ async onStateless({ payload, document }) {
+ // broadcast the client event (derived from the server event) to all the clients so that they can update their state
+ const response =
+ DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
+ if (response) {
+ document.broadcastStateless(response);
+ }
+ },
extensions,
debounce: 10000,
});
diff --git a/package.json b/package.json
index eb8b4fb227..f14aa4ac79 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
- "version": "0.23.1",
+ "version": "0.24.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -22,7 +22,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
- "turbo": "^2.3.0"
+ "turbo": "^2.3.3"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
diff --git a/packages/constants/index.ts b/packages/constants/index.ts
deleted file mode 100644
index 66089416f1..0000000000
--- a/packages/constants/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./auth";
-export * from "./issue";
\ No newline at end of file
diff --git a/packages/constants/issue.ts b/packages/constants/issue.ts
deleted file mode 100644
index 5db398c763..0000000000
--- a/packages/constants/issue.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export const ALL_ISSUES = "All Issues";
-
-export enum EIssueGroupByToServerOptions {
- "state" = "state_id",
- "priority" = "priority",
- "labels" = "labels__id",
- "state_detail.group" = "state__group",
- "assignees" = "assignees__id",
- "cycle" = "cycle_id",
- "module" = "issue_module__module_id",
- "target_date" = "target_date",
- "project" = "project_id",
- "created_by" = "created_by",
-}
-
-export enum EIssueGroupBYServerToProperty {
- "state_id" = "state_id",
- "priority" = "priority",
- "labels__id" = "label_ids",
- "state__group" = "state__group",
- "assignees__id" = "assignee_ids",
- "cycle_id" = "cycle_id",
- "issue_module__module_id" = "module_ids",
- "target_date" = "target_date",
- "project_id" = "project_id",
- "created_by" = "created_by",
-}
-
-export enum EServerGroupByToFilterOptions {
- "state_id" = "state",
- "priority" = "priority",
- "labels__id" = "labels",
- "state__group" = "state_group",
- "assignees__id" = "assignees",
- "cycle_id" = "cycle",
- "issue_module__module_id" = "module",
- "target_date" = "target_date",
- "project_id" = "project",
- "created_by" = "created_by",
-}
diff --git a/packages/constants/package.json b/packages/constants/package.json
index cdf51bbaf1..c1fe71a306 100644
--- a/packages/constants/package.json
+++ b/packages/constants/package.json
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
- "version": "0.23.1",
+ "version": "0.24.1",
"private": true,
- "main": "./index.ts"
+ "main": "./src/index.ts"
}
diff --git a/packages/constants/src/ai.ts b/packages/constants/src/ai.ts
new file mode 100644
index 0000000000..8125302440
--- /dev/null
+++ b/packages/constants/src/ai.ts
@@ -0,0 +1,3 @@
+export enum AI_EDITOR_TASKS {
+ ASK_ANYTHING = "ASK_ANYTHING",
+}
diff --git a/packages/constants/auth.ts b/packages/constants/src/auth.ts
similarity index 61%
rename from packages/constants/auth.ts
rename to packages/constants/src/auth.ts
index 59f08a37f0..884a8dd1c8 100644
--- a/packages/constants/auth.ts
+++ b/packages/constants/src/auth.ts
@@ -1,3 +1,36 @@
+export enum E_PASSWORD_STRENGTH {
+ EMPTY = "empty",
+ LENGTH_NOT_VALID = "length_not_valid",
+ STRENGTH_NOT_VALID = "strength_not_valid",
+ STRENGTH_VALID = "strength_valid",
+}
+
+export const PASSWORD_MIN_LENGTH = 8;
+
+export const PASSWORD_CRITERIA = [
+ {
+ key: "min_8_char",
+ label: "Min 8 characters",
+ isCriteriaValid: (password: string) =>
+ password.length >= PASSWORD_MIN_LENGTH,
+ },
+ // {
+ // key: "min_1_upper_case",
+ // label: "Min 1 upper-case letter",
+ // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
+ // },
+ // {
+ // key: "min_1_number",
+ // label: "Min 1 number",
+ // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
+ // },
+ // {
+ // key: "min_1_special_char",
+ // label: "Min 1 special character",
+ // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
+ // },
+];
+
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
@@ -6,6 +39,14 @@ export enum EAuthPageTypes {
AUTHENTICATED = "AUTHENTICATED",
}
+export enum EPageTypes {
+ INIT = "INIT",
+ PUBLIC = "PUBLIC",
+ NON_AUTHENTICATED = "NON_AUTHENTICATED",
+ ONBOARDING = "ONBOARDING",
+ AUTHENTICATED = "AUTHENTICATED",
+}
+
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
@@ -17,15 +58,35 @@ export enum EAuthSteps {
UNIQUE_CODE = "UNIQUE_CODE",
}
-// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
+ TOAST_ALERT = "TOAST_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
+export type TAuthErrorInfo = {
+ type: EErrorAlertType;
+ code: EAdminAuthErrorCodes;
+ title: string;
+ message: any;
+};
+
+export enum EAdminAuthErrorCodes {
+ // 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",
+}
+
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
@@ -74,7 +135,7 @@ export enum EAuthErrorCodes {
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
- // set passowrd
+ // set password
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
diff --git a/packages/constants/src/endpoints.ts b/packages/constants/src/endpoints.ts
new file mode 100644
index 0000000000..b17f7c1a06
--- /dev/null
+++ b/packages/constants/src/endpoints.ts
@@ -0,0 +1,25 @@
+export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
+export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
+export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
+// God Mode Admin App Base Url
+export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
+export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
+export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
+// Publish App Base Url
+export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
+export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
+export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
+// Live App Base Url
+export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
+export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
+export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
+// Web App Base Url
+export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
+export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
+export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
+// plane website url
+export const WEBSITE_URL =
+ process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
+// support email
+export const SUPPORT_EMAIL =
+ process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
diff --git a/space/core/constants/common.ts b/packages/constants/src/file.ts
similarity index 100%
rename from space/core/constants/common.ts
rename to packages/constants/src/file.ts
diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts
new file mode 100644
index 0000000000..95a4f97843
--- /dev/null
+++ b/packages/constants/src/index.ts
@@ -0,0 +1,11 @@
+export * from "./ai";
+export * from "./auth";
+export * from "./endpoints";
+export * from "./file";
+export * from "./instance";
+export * from "./issue";
+export * from "./metadata";
+export * from "./state";
+export * from "./swr";
+export * from "./user";
+export * from "./workspace";
diff --git a/admin/helpers/instance.helper.ts b/packages/constants/src/instance.ts
similarity index 100%
rename from admin/helpers/instance.helper.ts
rename to packages/constants/src/instance.ts
diff --git a/packages/constants/src/issue.ts b/packages/constants/src/issue.ts
new file mode 100644
index 0000000000..19cfe60f3a
--- /dev/null
+++ b/packages/constants/src/issue.ts
@@ -0,0 +1,185 @@
+import { List, Kanban } from "lucide-react";
+
+export const ALL_ISSUES = "All Issues";
+
+export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
+
+export type TIssueFilterKeys = "priority" | "state" | "labels";
+
+export type TIssueLayout =
+ | "list"
+ | "kanban"
+ | "calendar"
+ | "spreadsheet"
+ | "gantt";
+
+export type TIssueFilterPriorityObject = {
+ key: TIssuePriorities;
+ title: string;
+ className: string;
+ icon: string;
+};
+
+export enum EIssueGroupByToServerOptions {
+ "state" = "state_id",
+ "priority" = "priority",
+ "labels" = "labels__id",
+ "state_detail.group" = "state__group",
+ "assignees" = "assignees__id",
+ "cycle" = "cycle_id",
+ "module" = "issue_module__module_id",
+ "target_date" = "target_date",
+ "project" = "project_id",
+ "created_by" = "created_by",
+ "team_project" = "project_id",
+}
+
+export enum EIssueGroupBYServerToProperty {
+ "state_id" = "state_id",
+ "priority" = "priority",
+ "labels__id" = "label_ids",
+ "state__group" = "state__group",
+ "assignees__id" = "assignee_ids",
+ "cycle_id" = "cycle_id",
+ "issue_module__module_id" = "module_ids",
+ "target_date" = "target_date",
+ "project_id" = "project_id",
+ "created_by" = "created_by",
+}
+
+export enum EServerGroupByToFilterOptions {
+ "state_id" = "state",
+ "priority" = "priority",
+ "labels__id" = "labels",
+ "state__group" = "state_group",
+ "assignees__id" = "assignees",
+ "cycle_id" = "cycle",
+ "issue_module__module_id" = "module",
+ "target_date" = "target_date",
+ "project_id" = "project",
+ "created_by" = "created_by",
+}
+
+export enum EIssueServiceType {
+ ISSUES = "issues",
+ EPICS = "epics",
+}
+
+export enum EIssueLayoutTypes {
+ LIST = "list",
+ KANBAN = "kanban",
+ CALENDAR = "calendar",
+ GANTT = "gantt_chart",
+ SPREADSHEET = "spreadsheet",
+}
+
+export enum EIssuesStoreType {
+ GLOBAL = "GLOBAL",
+ PROFILE = "PROFILE",
+ TEAM = "TEAM",
+ PROJECT = "PROJECT",
+ CYCLE = "CYCLE",
+ MODULE = "MODULE",
+ TEAM_VIEW = "TEAM_VIEW",
+ PROJECT_VIEW = "PROJECT_VIEW",
+ ARCHIVED = "ARCHIVED",
+ DRAFT = "DRAFT",
+ DEFAULT = "DEFAULT",
+ WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
+ EPIC = "EPIC",
+}
+
+export enum EIssueFilterType {
+ FILTERS = "filters",
+ DISPLAY_FILTERS = "display_filters",
+ DISPLAY_PROPERTIES = "display_properties",
+ KANBAN_FILTERS = "kanban_filters",
+}
+
+export enum EIssueCommentAccessSpecifier {
+ EXTERNAL = "EXTERNAL",
+ INTERNAL = "INTERNAL",
+}
+
+export enum EIssueListRow {
+ HEADER = "HEADER",
+ ISSUE = "ISSUE",
+ NO_ISSUES = "NO_ISSUES",
+ QUICK_ADD = "QUICK_ADD",
+}
+
+export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
+ [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>;
+} = {
+ list: {
+ filters: ["priority", "state", "labels"],
+ },
+ kanban: {
+ filters: ["priority", "state", "labels"],
+ },
+ calendar: {
+ filters: ["priority", "state", "labels"],
+ },
+ spreadsheet: {
+ filters: ["priority", "state", "labels"],
+ },
+ gantt: {
+ filters: ["priority", "state", "labels"],
+ },
+};
+
+export const ISSUE_PRIORITIES: {
+ key: TIssuePriorities;
+ title: string;
+}[] = [
+ { key: "urgent", title: "Urgent" },
+ { key: "high", title: "High" },
+ { key: "medium", title: "Medium" },
+ { key: "low", title: "Low" },
+ { key: "none", title: "None" },
+];
+
+export const ISSUE_PRIORITY_FILTERS: TIssueFilterPriorityObject[] = [
+ {
+ key: "urgent",
+ title: "Urgent",
+ className: "bg-red-500 border-red-500 text-white",
+ icon: "error",
+ },
+ {
+ key: "high",
+ title: "High",
+ className: "text-orange-500 border-custom-border-300",
+ icon: "signal_cellular_alt",
+ },
+ {
+ key: "medium",
+ title: "Medium",
+ className: "text-yellow-500 border-custom-border-300",
+ icon: "signal_cellular_alt_2_bar",
+ },
+ {
+ key: "low",
+ title: "Low",
+ className: "text-green-500 border-custom-border-300",
+ icon: "signal_cellular_alt_1_bar",
+ },
+ {
+ key: "none",
+ title: "None",
+ className: "text-gray-500 border-custom-border-300",
+ icon: "block",
+ },
+];
+
+export const SITES_ISSUE_LAYOUTS: {
+ key: TIssueLayout;
+ title: string;
+ icon: any;
+}[] = [
+ { key: "list", title: "List", icon: List },
+ { key: "kanban", title: "Kanban", icon: Kanban },
+ // { key: "calendar", title: "Calendar", icon: Calendar },
+ // { key: "spreadsheet", title: "Spreadsheet", icon: Sheet },
+ // { key: "gantt", title: "Gantt chart", icon: GanttChartSquare },
+];
diff --git a/packages/constants/src/metadata.ts b/packages/constants/src/metadata.ts
new file mode 100644
index 0000000000..b356388267
--- /dev/null
+++ b/packages/constants/src/metadata.ts
@@ -0,0 +1,23 @@
+export const SITE_NAME =
+ "Plane | Simple, extensible, open-source project management tool.";
+export const SITE_TITLE =
+ "Plane | Simple, extensible, open-source project management tool.";
+export const SITE_DESCRIPTION =
+ "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
+export const SITE_KEYWORDS =
+ "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
+export const SITE_URL = "https://app.plane.so/";
+export const TWITTER_USER_NAME =
+ "Plane | Simple, extensible, open-source project management tool.";
+
+// Plane Sites Metadata
+export const SPACE_SITE_NAME =
+ "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. ";
+export const SPACE_SITE_TITLE =
+ "Plane Publish | Make your Plane boards public with one-click";
+export const SPACE_SITE_DESCRIPTION =
+ "Plane Publish is a customer feedback management tool built on top of plane.so";
+export const SPACE_SITE_KEYWORDS =
+ "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
+export const SPACE_SITE_URL = "https://app.plane.so/";
+export const SPACE_TWITTER_USER_NAME = "planepowers";
diff --git a/space/core/constants/state.ts b/packages/constants/src/state.ts
similarity index 72%
rename from space/core/constants/state.ts
rename to packages/constants/src/state.ts
index b0fd622be0..c51728bf9a 100644
--- a/space/core/constants/state.ts
+++ b/packages/constants/src/state.ts
@@ -1,4 +1,9 @@
-import { TStateGroups } from "@plane/types";
+export type TStateGroups =
+ | "backlog"
+ | "unstarted"
+ | "started"
+ | "completed"
+ | "cancelled";
export const STATE_GROUPS: {
[key in TStateGroups]: {
@@ -34,4 +39,7 @@ export const STATE_GROUPS: {
},
};
-export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key];
+export const ARCHIVABLE_STATE_GROUPS = [
+ STATE_GROUPS.completed.key,
+ STATE_GROUPS.cancelled.key,
+];
diff --git a/admin/core/constants/swr-config.ts b/packages/constants/src/swr.ts
similarity index 81%
rename from admin/core/constants/swr-config.ts
rename to packages/constants/src/swr.ts
index 38478fcea3..d3eef1cdfe 100644
--- a/admin/core/constants/swr-config.ts
+++ b/packages/constants/src/swr.ts
@@ -1,4 +1,4 @@
-export const SWR_CONFIG = {
+export const DEFAULT_SWR_CONFIG = {
refreshWhenHidden: false,
revalidateIfStale: false,
revalidateOnFocus: false,
diff --git a/admin/helpers/user.helper.ts b/packages/constants/src/user.ts
similarity index 100%
rename from admin/helpers/user.helper.ts
rename to packages/constants/src/user.ts
diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts
new file mode 100644
index 0000000000..c17b5432ee
--- /dev/null
+++ b/packages/constants/src/workspace.ts
@@ -0,0 +1,76 @@
+export const ORGANIZATION_SIZE = [
+ "Just myself",
+ "2-10",
+ "11-50",
+ "51-200",
+ "201-500",
+ "500+",
+];
+
+export const RESTRICTED_URLS = [
+ "404",
+ "accounts",
+ "api",
+ "create-workspace",
+ "god-mode",
+ "installations",
+ "invitations",
+ "onboarding",
+ "profile",
+ "spaces",
+ "workspace-invitations",
+ "password",
+ "flags",
+ "monitor",
+ "monitoring",
+ "ingest",
+ "plane-pro",
+ "plane-ultimate",
+ "enterprise",
+ "plane-enterprise",
+ "disco",
+ "silo",
+ "chat",
+ "calendar",
+ "drive",
+ "channels",
+ "upgrade",
+ "billing",
+ "sign-in",
+ "sign-up",
+ "signin",
+ "signup",
+ "config",
+ "live",
+ "admin",
+ "m",
+ "import",
+ "importers",
+ "integrations",
+ "integration",
+ "configuration",
+ "initiatives",
+ "initiative",
+ "config",
+ "workflow",
+ "workflows",
+ "epics",
+ "epic",
+ "story",
+ "mobile",
+ "dashboard",
+ "desktop",
+ "onload",
+ "real-time",
+ "one",
+ "pages",
+ "mobile",
+ "business",
+ "pro",
+ "settings",
+ "monitor",
+ "license",
+ "licenses",
+ "instances",
+ "instance",
+];
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 067be0145d..e9ef145a0c 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
- "version": "0.23.1",
+ "version": "0.24.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -27,6 +27,7 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
+ "lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -36,8 +37,9 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
- "@plane/helpers": "*",
+ "@plane/types": "*",
"@plane/ui": "*",
+ "@plane/utils": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
@@ -56,7 +58,6 @@
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.0.13",
"class-variance-authority": "^0.7.0",
- "clsx": "^1.2.1",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.1.3",
@@ -65,7 +66,6 @@
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
- "tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
diff --git a/packages/editor/src/ce/extensions/core/extensions.ts b/packages/editor/src/ce/extensions/core/extensions.ts
new file mode 100644
index 0000000000..d03229133b
--- /dev/null
+++ b/packages/editor/src/ce/extensions/core/extensions.ts
@@ -0,0 +1,12 @@
+import { Extensions } from "@tiptap/core";
+// types
+import { TExtensions } from "@/types";
+
+type Props = {
+ disabledExtensions: TExtensions[];
+};
+
+export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
+ const {} = props;
+ return [];
+};
diff --git a/packages/editor/src/ce/extensions/core/index.ts b/packages/editor/src/ce/extensions/core/index.ts
new file mode 100644
index 0000000000..9ffc978c3f
--- /dev/null
+++ b/packages/editor/src/ce/extensions/core/index.ts
@@ -0,0 +1,2 @@
+export * from "./extensions";
+export * from "./read-only-extensions";
diff --git a/packages/editor/src/ce/extensions/core/read-only-extensions.ts b/packages/editor/src/ce/extensions/core/read-only-extensions.ts
new file mode 100644
index 0000000000..398848e31d
--- /dev/null
+++ b/packages/editor/src/ce/extensions/core/read-only-extensions.ts
@@ -0,0 +1,12 @@
+import { Extensions } from "@tiptap/core";
+// types
+import { TExtensions } from "@/types";
+
+type Props = {
+ disabledExtensions: TExtensions[];
+};
+
+export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
+ const {} = props;
+ return [];
+};
diff --git a/packages/editor/src/ce/extensions/core/without-props.ts b/packages/editor/src/ce/extensions/core/without-props.ts
new file mode 100644
index 0000000000..0debff0ea5
--- /dev/null
+++ b/packages/editor/src/ce/extensions/core/without-props.ts
@@ -0,0 +1,3 @@
+import { Extensions } from "@tiptap/core";
+
+export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx
index e3c94fa0e1..35d7c0f3de 100644
--- a/packages/editor/src/ce/extensions/document-extensions.tsx
+++ b/packages/editor/src/ce/extensions/document-extensions.tsx
@@ -15,7 +15,13 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
- const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
+ const extensions: Extensions = disabledExtensions?.includes("slash-commands")
+ ? []
+ : [
+ SlashCommands({
+ disabledExtensions,
+ }),
+ ];
return extensions;
};
diff --git a/packages/editor/src/ce/extensions/index.ts b/packages/editor/src/ce/extensions/index.ts
index 4a975b8c5a..c9f58a936a 100644
--- a/packages/editor/src/ce/extensions/index.ts
+++ b/packages/editor/src/ce/extensions/index.ts
@@ -1 +1,3 @@
+export * from "./core";
export * from "./document-extensions";
+export * from "./slash-commands";
diff --git a/packages/editor/src/ce/extensions/slash-commands.tsx b/packages/editor/src/ce/extensions/slash-commands.tsx
new file mode 100644
index 0000000000..6eabee0823
--- /dev/null
+++ b/packages/editor/src/ce/extensions/slash-commands.tsx
@@ -0,0 +1,14 @@
+// extensions
+import { TSlashCommandAdditionalOption } from "@/extensions";
+// types
+import { TExtensions } from "@/types";
+
+type Props = {
+ disabledExtensions: TExtensions[];
+};
+
+export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
+ const {} = props;
+ const options: TSlashCommandAdditionalOption[] = [];
+ return options;
+};
diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx
index cd7d6f3548..44c18c2f68 100644
--- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx
+++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
+ editable,
editorClassName = "",
embedHandler,
fileHandler,
@@ -44,8 +45,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
- onTransaction,
disabledExtensions,
+ editable,
editorClassName,
embedHandler,
extensions,
@@ -54,6 +55,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
handleEditorReady,
id,
mentionHandler,
+ onTransaction,
placeholder,
realtimeConfig,
serverHandler,
diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx
deleted file mode 100644
index aa925abece..0000000000
--- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { forwardRef, MutableRefObject } from "react";
-// components
-import { DocumentContentLoader, PageRenderer } from "@/components/editors";
-// constants
-import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
-// extensions
-import { IssueWidget } from "@/extensions";
-// helpers
-import { getEditorClassNames } from "@/helpers/common";
-// hooks
-import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
-// types
-import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
-
-const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
- const {
- containerClassName,
- displayConfig = DEFAULT_DISPLAY_CONFIG,
- editorClassName = "",
- embedHandler,
- fileHandler,
- forwardedRef,
- handleEditorReady,
- id,
- mentionHandler,
- realtimeConfig,
- serverHandler,
- user,
- } = props;
- const extensions = [];
- if (embedHandler?.issue) {
- extensions.push(
- IssueWidget({
- widgetCallback: embedHandler.issue.widgetCallback,
- })
- );
- }
-
- const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
- editorClassName,
- extensions,
- fileHandler,
- forwardedRef,
- handleEditorReady,
- id,
- mentionHandler,
- realtimeConfig,
- serverHandler,
- user,
- });
-
- const editorContainerClassName = getEditorClassNames({
- containerClassName,
- });
-
- if (!editor) return null;
-
- if (!hasServerSynced && !hasServerConnectionFailed) return ;
-
- return (
-
- );
-};
-
-const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef<
- EditorReadOnlyRefApi,
- ICollaborativeDocumentReadOnlyEditor
->((props, ref) => (
- } />
-));
-
-CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef";
-
-export { CollaborativeDocumentReadOnlyEditorWithRef };
diff --git a/packages/editor/src/core/components/editors/document/index.ts b/packages/editor/src/core/components/editors/document/index.ts
index 514b620e3a..571cb7e9a1 100644
--- a/packages/editor/src/core/components/editors/document/index.ts
+++ b/packages/editor/src/core/components/editors/document/index.ts
@@ -1,5 +1,4 @@
export * from "./collaborative-editor";
-export * from "./collaborative-read-only-editor";
export * from "./loader";
export * from "./page-renderer";
export * from "./read-only-editor";
diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx
index d1ff3b3d01..f291c8b3a3 100644
--- a/packages/editor/src/core/components/editors/document/page-renderer.tsx
+++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx
@@ -140,10 +140,10 @@ export const PageRenderer = (props: IPageRenderer) => {
>
{editor.isEditable && (
- <>
+
)}
diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx
index 8544157aa0..0e8ab63f8a 100644
--- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx
+++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
-import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
+import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
interface IDocumentReadOnlyEditor {
+ disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
@@ -22,15 +23,14 @@ interface IDocumentReadOnlyEditor {
fileHandler: Pick