Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1053ff4684 |
@@ -61,9 +61,3 @@ temp/
|
||||
# Misc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore
|
||||
.react-router/
|
||||
build/
|
||||
node_modules/
|
||||
README.md
|
||||
+1
-2
@@ -16,10 +16,9 @@ node_modules
|
||||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
dist/
|
||||
out/
|
||||
build/
|
||||
.react-router/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
rules: {
|
||||
"no-duplicate-imports": "off",
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "separate-type-imports",
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
|
||||
// components
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Artificial Intelligence Settings - God Mode",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -10,16 +9,14 @@ import { Monitor } from "lucide-react";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GitHub Authentication - God Mode",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -7,15 +6,13 @@ import { useForm } from "react-hook-form";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GitLab Authentication - God Mode",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -9,15 +8,13 @@ import { Monitor } from "lucide-react";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CodeBlock } from "@/components/common/code-block";
|
||||
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import type { TCopyField } from "@/components/common/copy-field";
|
||||
import { CopyField } from "@/components/common/copy-field";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { CopyField, TCopyField } from "@/components/common/copy-field";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Google Authentication - God Mode",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authentication Settings - Plane Web",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import type { TControllerInputFormField } from "@/components/common/controller-input";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// local components
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
interface EmailLayoutProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Telescope } from "lucide-react";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IInstance, IInstanceAdmin } from "@plane/types";
|
||||
import { IInstance, IInstanceAdmin } from "@plane/types";
|
||||
// ui
|
||||
import { Input, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "General Settings - God Mode",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
|
||||
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
|
||||
// components
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
interface ImageLayoutProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// components
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { FC, useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -21,7 +20,7 @@ const helpOptions = [
|
||||
{
|
||||
name: "Documentation",
|
||||
href: "https://docs.plane.so/",
|
||||
Icon: PageIcon,
|
||||
Icon: FileText,
|
||||
},
|
||||
{
|
||||
name: "Join our Discord",
|
||||
@@ -111,7 +110,7 @@ export const AdminSidebarHelpSection: FC = observer(() => {
|
||||
<Link href={href} key={name} target="_blank">
|
||||
<div className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
<Icon className="h-3.5 w-3.5 text-custom-text-200" width={14} height={14} />
|
||||
<Icon className="h-3.5 w-3.5 text-custom-text-200" size={14} />
|
||||
</div>
|
||||
<span className="text-xs">{name}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constan
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
// components
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// hooks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workspace Management - God Mode",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Loader as LoaderIcon } from "lucide-react";
|
||||
// types
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane constants
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAdminAuthErrorInfo | undefined;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// plane packages
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
|
||||
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext } from "react";
|
||||
import { ReactNode, createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
// plane imports
|
||||
import { ADMIN_BASE_PATH } from "@plane/constants";
|
||||
// styles
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
import type {
|
||||
import {
|
||||
TGetBaseAuthenticationModeProps,
|
||||
TInstanceAuthenticationMethodKeys,
|
||||
TInstanceAuthenticationModes,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from "next/link";
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from "next/link";
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from "next/link";
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type TBanner = {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IInstanceStore } from "@/store/instance.store";
|
||||
import { IInstanceStore } from "@/store/instance.store";
|
||||
|
||||
export const useInstance = (): IInstanceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IThemeStore } from "@/store/theme.store";
|
||||
import { IThemeStore } from "@/store/theme.store";
|
||||
|
||||
export const useTheme = (): IThemeStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IUserStore } from "@/store/user.store";
|
||||
import { IUserStore } from "@/store/user.store";
|
||||
|
||||
export const useUser = (): IUserStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/(all)/store.provider";
|
||||
import type { IWorkspaceStore } from "@/store/workspace.store";
|
||||
import { IWorkspaceStore } from "@/store/workspace.store";
|
||||
|
||||
export const useWorkspace = (): IWorkspaceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { set } from "lodash-es";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import type { TInstanceStatus } from "@plane/constants";
|
||||
import { EInstanceStatus } from "@plane/constants";
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
import { InstanceService } from "@plane/services";
|
||||
import type {
|
||||
import {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
IInstanceConfiguration,
|
||||
@@ -13,7 +12,7 @@ import type {
|
||||
IInstanceConfig,
|
||||
} from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import type { IInstanceStore } from "./instance.store";
|
||||
import { InstanceStore } from "./instance.store";
|
||||
import type { IThemeStore } from "./theme.store";
|
||||
import { ThemeStore } from "./theme.store";
|
||||
import type { IUserStore } from "./user.store";
|
||||
import { UserStore } from "./user.store";
|
||||
import type { IWorkspaceStore } from "./workspace.store";
|
||||
import { WorkspaceStore } from "./workspace.store";
|
||||
import { IInstanceStore, InstanceStore } from "./instance.store";
|
||||
import { IThemeStore, ThemeStore } from "./theme.store";
|
||||
import { IUserStore, UserStore } from "./user.store";
|
||||
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { action, observable, makeObservable } from "mobx";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TTheme = "dark" | "light";
|
||||
export interface IThemeStore {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { action, observable, runInAction, makeObservable } from "mobx";
|
||||
// plane internal packages
|
||||
import type { TUserStatus } from "@plane/constants";
|
||||
import { EUserStatus } from "@plane/constants";
|
||||
import { EUserStatus, TUserStatus } from "@plane/constants";
|
||||
import { AuthService, UserService } from "@plane/services";
|
||||
import type { IUser } from "@plane/types";
|
||||
import { IUser } from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
|
||||
@@ -2,9 +2,9 @@ import { set } from "lodash-es";
|
||||
import { action, observable, runInAction, makeObservable, computed } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
// observables
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
|
||||
# Module import
|
||||
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
from plane.db.models.user import get_default_tour_completed_features
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
@@ -194,6 +194,37 @@ class ResetPasswordSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class ProfileSerializer(BaseSerializer):
|
||||
|
||||
def validate_tour_completed_features(self, value):
|
||||
"""
|
||||
Clean tour_completed_features by removing invalid keys and keeping only
|
||||
the keys present in get_default_tour_completed_features function.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return get_default_tour_completed_features()
|
||||
|
||||
expected_structure = get_default_tour_completed_features()
|
||||
|
||||
# Use dict comprehension for cleaner, more efficient code
|
||||
cleaned_data = {}
|
||||
|
||||
# Process valid top-level keys
|
||||
for key in expected_structure:
|
||||
if key in value:
|
||||
val = value[key]
|
||||
if isinstance(val, dict) and key in ['workspace_features', 'project_features']:
|
||||
# Clean nested structure using intersection
|
||||
expected_nested = expected_structure[key]
|
||||
cleaned_data[key] = {k: v for k, v in val.items() if k in expected_nested}
|
||||
else:
|
||||
cleaned_data[key] = val
|
||||
else:
|
||||
# Add missing key with default
|
||||
default_value = expected_structure[key]
|
||||
cleaned_data[key] = default_value.copy() if isinstance(default_value, dict) else default_value
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = "__all__"
|
||||
|
||||
@@ -410,8 +410,7 @@ def get_webhook_logs_queryset():
|
||||
"response_headers",
|
||||
"retry_count",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.iterator(chunk_size=100)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +1,82 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from typing import List
|
||||
from collections import defaultdict
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Prefetch
|
||||
from openpyxl import Workbook
|
||||
from django.db.models import F, Prefetch
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory, Issue, IssueRelation
|
||||
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
|
||||
|
||||
def dateTimeConverter(time: datetime) -> str | None:
|
||||
"""
|
||||
Convert a datetime object to a formatted string.
|
||||
"""
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
|
||||
|
||||
def dateConverter(time: date) -> str | None:
|
||||
"""
|
||||
Convert a date object to a formatted string.
|
||||
"""
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y")
|
||||
|
||||
|
||||
def create_csv_file(data: List[List[str]]) -> str:
|
||||
"""
|
||||
Create a CSV file from the provided data.
|
||||
"""
|
||||
csv_buffer = io.StringIO()
|
||||
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
for row in data:
|
||||
csv_writer.writerow(row)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
|
||||
def create_json_file(data: List[dict]) -> str:
|
||||
"""
|
||||
Create a JSON file from the provided data.
|
||||
"""
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def create_xlsx_file(data: List[List[str]]) -> bytes:
|
||||
"""
|
||||
Create an XLSX file from the provided data.
|
||||
"""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
|
||||
for row in data:
|
||||
sheet.append(row)
|
||||
|
||||
xlsx_buffer = io.BytesIO()
|
||||
workbook.save(xlsx_buffer)
|
||||
xlsx_buffer.seek(0)
|
||||
return xlsx_buffer.getvalue()
|
||||
|
||||
|
||||
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
|
||||
@@ -60,9 +118,7 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
|
||||
# Generate presigned url for the uploaded file with different base
|
||||
presign_s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=(
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/"
|
||||
),
|
||||
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", # noqa: E501
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
@@ -120,6 +176,187 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
|
||||
exporter_instance.save(update_fields=["status", "url", "key"])
|
||||
|
||||
|
||||
def generate_table_row(issue: dict) -> List[str]:
|
||||
"""
|
||||
Generate a table row from an issue dictionary.
|
||||
"""
|
||||
return [
|
||||
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
|
||||
issue["project_name"],
|
||||
issue["name"],
|
||||
issue["description"],
|
||||
issue["state_name"],
|
||||
dateConverter(issue["start_date"]),
|
||||
dateConverter(issue["target_date"]),
|
||||
issue["priority"],
|
||||
issue["created_by"],
|
||||
", ".join(issue["labels"]) if issue["labels"] else "",
|
||||
issue["cycle_name"],
|
||||
issue["cycle_start_date"],
|
||||
issue["cycle_end_date"],
|
||||
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
|
||||
dateTimeConverter(issue["created_at"]),
|
||||
dateTimeConverter(issue["updated_at"]),
|
||||
dateTimeConverter(issue["completed_at"]),
|
||||
dateTimeConverter(issue["archived_at"]),
|
||||
(
|
||||
", ".join(
|
||||
[
|
||||
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
|
||||
for comment in issue["comments"]
|
||||
]
|
||||
)
|
||||
if issue["comments"]
|
||||
else ""
|
||||
),
|
||||
issue["estimate"] if issue["estimate"] else "",
|
||||
", ".join(issue["link"]) if issue["link"] else "",
|
||||
", ".join(issue["assignees"]) if issue["assignees"] else "",
|
||||
issue["subscribers_count"] if issue["subscribers_count"] else "",
|
||||
issue["attachment_count"] if issue["attachment_count"] else "",
|
||||
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
|
||||
]
|
||||
|
||||
|
||||
def generate_json_row(issue: dict) -> dict:
|
||||
"""
|
||||
Generate a JSON row from an issue dictionary.
|
||||
"""
|
||||
return {
|
||||
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
|
||||
"Project": issue["project_name"],
|
||||
"Name": issue["name"],
|
||||
"Description": issue["description"],
|
||||
"State": issue["state_name"],
|
||||
"Start Date": dateConverter(issue["start_date"]),
|
||||
"Target Date": dateConverter(issue["target_date"]),
|
||||
"Priority": issue["priority"],
|
||||
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
|
||||
"Assignee": issue["assignees"],
|
||||
"Labels": issue["labels"],
|
||||
"Cycle Name": issue["cycle_name"],
|
||||
"Cycle Start Date": issue["cycle_start_date"],
|
||||
"Cycle End Date": issue["cycle_end_date"],
|
||||
"Module Name": issue["module_name"],
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
"Archived At": dateTimeConverter(issue["archived_at"]),
|
||||
"Comments": issue["comments"],
|
||||
"Estimate": issue["estimate"],
|
||||
"Link": issue["link"],
|
||||
"Subscribers Count": issue["subscribers_count"],
|
||||
"Attachment Count": issue["attachment_count"],
|
||||
"Attachment Links": issue["attachment_links"],
|
||||
}
|
||||
|
||||
|
||||
def update_json_row(rows: List[dict], row: dict) -> None:
|
||||
"""
|
||||
Update the json row with the new assignee and label.
|
||||
"""
|
||||
matched_index = next(
|
||||
(index for index, existing_row in enumerate(rows) if existing_row["ID"] == row["ID"]),
|
||||
None,
|
||||
)
|
||||
|
||||
if matched_index is not None:
|
||||
existing_assignees, existing_labels = (
|
||||
rows[matched_index]["Assignee"],
|
||||
rows[matched_index]["Labels"],
|
||||
)
|
||||
assignee, label = row["Assignee"], row["Labels"]
|
||||
|
||||
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
|
||||
rows[matched_index]["Assignee"] += f", {assignee}"
|
||||
if label is not None and (existing_labels is None or label not in existing_labels):
|
||||
rows[matched_index]["Labels"] += f", {label}"
|
||||
else:
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
|
||||
"""
|
||||
Update the table row with the new assignee and label.
|
||||
"""
|
||||
matched_index = next(
|
||||
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
|
||||
None,
|
||||
)
|
||||
|
||||
if matched_index is not None:
|
||||
existing_assignees, existing_labels = rows[matched_index][7:9]
|
||||
assignee, label = row[7:9]
|
||||
|
||||
if assignee is not None and (existing_assignees is None or label not in existing_assignees):
|
||||
rows[matched_index][8] += f", {assignee}"
|
||||
if label is not None and (existing_labels is None or label not in existing_labels):
|
||||
rows[matched_index][8] += f", {label}"
|
||||
else:
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def generate_csv(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate CSV export for all the passed issues.
|
||||
"""
|
||||
rows = [header]
|
||||
for issue in issues:
|
||||
row = generate_table_row(issue)
|
||||
update_table_row(rows, row)
|
||||
csv_file = create_csv_file(rows)
|
||||
files.append((f"{project_id}.csv", csv_file))
|
||||
|
||||
|
||||
def generate_json(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate JSON export for all the passed issues.
|
||||
"""
|
||||
rows = []
|
||||
for issue in issues:
|
||||
row = generate_json_row(issue)
|
||||
update_json_row(rows, row)
|
||||
json_file = create_json_file(rows)
|
||||
files.append((f"{project_id}.json", json_file))
|
||||
|
||||
|
||||
def generate_xlsx(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate XLSX export for all the passed issues.
|
||||
"""
|
||||
rows = [header]
|
||||
for issue in issues:
|
||||
row = generate_table_row(issue)
|
||||
|
||||
update_table_row(rows, row)
|
||||
xlsx_file = create_xlsx_file(rows)
|
||||
files.append((f"{project_id}.xlsx", xlsx_file))
|
||||
|
||||
|
||||
def get_created_by(obj: Issue | IssueComment) -> str:
|
||||
"""
|
||||
Get the created by user for the given object.
|
||||
"""
|
||||
if obj.created_by:
|
||||
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
|
||||
return ""
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(
|
||||
provider: str,
|
||||
@@ -140,7 +377,7 @@ def issue_export_task(
|
||||
exporter_instance.status = "processing"
|
||||
exporter_instance.save(update_fields=["status"])
|
||||
|
||||
# Build base queryset for issues
|
||||
# Base query to get the issues
|
||||
workspace_issues = (
|
||||
Issue.objects.filter(
|
||||
workspace__id=workspace_id,
|
||||
@@ -153,6 +390,7 @@ def issue_export_task(
|
||||
"project",
|
||||
"workspace",
|
||||
"state",
|
||||
"parent",
|
||||
"created_by",
|
||||
"estimate_point",
|
||||
)
|
||||
@@ -162,51 +400,144 @@ def issue_export_task(
|
||||
"issue_module__module",
|
||||
"issue_comments",
|
||||
"assignees",
|
||||
Prefetch(
|
||||
"assignees",
|
||||
queryset=User.objects.only("first_name", "last_name").distinct(),
|
||||
to_attr="assignee_details",
|
||||
),
|
||||
Prefetch(
|
||||
"labels",
|
||||
queryset=Label.objects.only("name").distinct(),
|
||||
to_attr="label_details",
|
||||
),
|
||||
"issue_subscribers",
|
||||
"issue_link",
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_related",
|
||||
queryset=IssueRelation.objects.select_related("issue", "issue__project"),
|
||||
),
|
||||
Prefetch(
|
||||
"parent",
|
||||
queryset=Issue.objects.select_related("type", "project"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Create exporter for the specified format
|
||||
try:
|
||||
exporter = Exporter(
|
||||
format_type=provider,
|
||||
schema_class=IssueExportSchema,
|
||||
options={"list_joiner": ", "},
|
||||
)
|
||||
except ValueError as e:
|
||||
# Invalid format type
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
return
|
||||
# Get the attachments for the issues
|
||||
file_assets = FileAsset.objects.filter(
|
||||
issue_id__in=workspace_issues.values_list("id", flat=True),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
|
||||
|
||||
# Create a dictionary to store the attachments for the issues
|
||||
attachment_dict = defaultdict(list)
|
||||
for asset in file_assets:
|
||||
attachment_dict[asset.work_item_id].append(asset.asset_id)
|
||||
|
||||
# Create a list to store the issues data
|
||||
issues_data = []
|
||||
|
||||
# Iterate over the issues
|
||||
for issue in workspace_issues:
|
||||
attachments = attachment_dict.get(issue.id, [])
|
||||
|
||||
issue_data = {
|
||||
"id": issue.id,
|
||||
"project_identifier": issue.project.identifier,
|
||||
"project_name": issue.project.name,
|
||||
"project_id": issue.project.id,
|
||||
"sequence_id": issue.sequence_id,
|
||||
"name": issue.name,
|
||||
"description": issue.description_stripped,
|
||||
"priority": issue.priority,
|
||||
"start_date": issue.start_date,
|
||||
"target_date": issue.target_date,
|
||||
"state_name": issue.state.name if issue.state else None,
|
||||
"created_at": issue.created_at,
|
||||
"updated_at": issue.updated_at,
|
||||
"completed_at": issue.completed_at,
|
||||
"archived_at": issue.archived_at,
|
||||
"module_name": [module.module.name for module in issue.issue_module.all()],
|
||||
"created_by": get_created_by(issue),
|
||||
"labels": [label.name for label in issue.label_details],
|
||||
"comments": [
|
||||
{
|
||||
"comment": comment.comment_stripped,
|
||||
"created_at": dateConverter(comment.created_at),
|
||||
"created_by": get_created_by(comment),
|
||||
}
|
||||
for comment in issue.issue_comments.all()
|
||||
],
|
||||
"estimate": issue.estimate_point.value if issue.estimate_point and issue.estimate_point.value else "",
|
||||
"link": [link.url for link in issue.issue_link.all()],
|
||||
"assignees": [f"{assignee.first_name} {assignee.last_name}" for assignee in issue.assignee_details],
|
||||
"subscribers_count": issue.issue_subscribers.count(),
|
||||
"attachment_count": len(attachments),
|
||||
"attachment_links": [
|
||||
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
|
||||
for asset in attachments
|
||||
],
|
||||
}
|
||||
|
||||
# Get Cycles data for the issue
|
||||
cycle = issue.issue_cycle.last()
|
||||
if cycle:
|
||||
# Update cycle data
|
||||
issue_data["cycle_name"] = cycle.cycle.name
|
||||
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
|
||||
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
|
||||
else:
|
||||
issue_data["cycle_name"] = ""
|
||||
issue_data["cycle_start_date"] = ""
|
||||
issue_data["cycle_end_date"] = ""
|
||||
|
||||
issues_data.append(issue_data)
|
||||
|
||||
# CSV header
|
||||
header = [
|
||||
"ID",
|
||||
"Project",
|
||||
"Name",
|
||||
"Description",
|
||||
"State",
|
||||
"Start Date",
|
||||
"Target Date",
|
||||
"Priority",
|
||||
"Created By",
|
||||
"Labels",
|
||||
"Cycle Name",
|
||||
"Cycle Start Date",
|
||||
"Cycle End Date",
|
||||
"Module Name",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
"Completed At",
|
||||
"Archived At",
|
||||
"Comments",
|
||||
"Estimate",
|
||||
"Link",
|
||||
"Assignees",
|
||||
"Subscribers Count",
|
||||
"Attachment Count",
|
||||
"Attachment Links",
|
||||
]
|
||||
|
||||
# Map the provider to the function
|
||||
EXPORTER_MAPPER = {
|
||||
"csv": generate_csv,
|
||||
"json": generate_json,
|
||||
"xlsx": generate_xlsx,
|
||||
}
|
||||
|
||||
files = []
|
||||
if multiple:
|
||||
# Export each project separately with its own queryset
|
||||
project_dict = defaultdict(list)
|
||||
for issue in issues_data:
|
||||
project_dict[str(issue["project_id"])].append(issue)
|
||||
|
||||
for project_id in project_ids:
|
||||
project_issues = workspace_issues.filter(project_id=project_id)
|
||||
export_filename = f"{slug}-{project_id}"
|
||||
filename, content = exporter.export(export_filename, project_issues)
|
||||
files.append((filename, content))
|
||||
issues = project_dict.get(str(project_id), [])
|
||||
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(header, project_id, issues, files)
|
||||
|
||||
else:
|
||||
# Export all issues in a single file
|
||||
export_filename = f"{slug}-{workspace_id}"
|
||||
filename, content = exporter.export(export_filename, workspace_issues)
|
||||
files.append((filename, content))
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(header, workspace_id, issues_data, files)
|
||||
|
||||
zip_buffer = create_zip_file(files)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
|
||||
|
||||
@@ -1154,10 +1154,7 @@ def create_comment_reaction_activity(
|
||||
.values_list("id", "comment__id")
|
||||
.first()
|
||||
)
|
||||
comment = IssueComment.objects.filter(pk=comment_id, project_id=project_id).first()
|
||||
if comment is None:
|
||||
return
|
||||
|
||||
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
|
||||
if comment is not None and comment_reaction_id is not None and comment_id is not None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
|
||||
@@ -48,8 +48,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
@@ -86,58 +84,6 @@ def get_issue_prefetches():
|
||||
]
|
||||
|
||||
|
||||
|
||||
def save_webhook_log(
|
||||
webhook: Webhook,
|
||||
request_method: str,
|
||||
request_headers: str,
|
||||
request_body: str,
|
||||
response_status: str,
|
||||
response_headers: str,
|
||||
response_body: str,
|
||||
retry_count: int,
|
||||
event_type: str,
|
||||
) -> None:
|
||||
|
||||
# webhook_logs
|
||||
mongo_collection = MongoConnection.get_collection("webhook_logs")
|
||||
|
||||
log_data = {
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"webhook": str(webhook.id),
|
||||
"event_type": str(event_type),
|
||||
"request_method": str(request_method),
|
||||
"request_headers": str(request_headers),
|
||||
"request_body": str(request_body),
|
||||
"response_status": str(response_status),
|
||||
"response_headers": str(response_headers),
|
||||
"response_body": str(response_body),
|
||||
"retry_count": retry_count,
|
||||
}
|
||||
|
||||
mongo_save_success = False
|
||||
if mongo_collection is not None:
|
||||
try:
|
||||
# insert the log data into the mongo collection
|
||||
mongo_collection.insert_one(log_data)
|
||||
logger.info("Webhook log saved successfully to mongo")
|
||||
mongo_save_success = True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
mongo_save_success = False
|
||||
|
||||
# if the mongo save is not successful, save the log data into the database
|
||||
if not mongo_save_success:
|
||||
try:
|
||||
# insert the log data into the database
|
||||
WebhookLog.objects.create(**log_data)
|
||||
logger.info("Webhook log saved successfully to database")
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
|
||||
|
||||
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve and serialize model data based on the event type.
|
||||
@@ -327,30 +273,32 @@ def webhook_send_task(
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
# Log the webhook request
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
response_status=response.status_code,
|
||||
response_headers=response.headers,
|
||||
response_body=response.text,
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=str(response.status_code),
|
||||
response_headers=str(response.headers),
|
||||
response_body=str(response.text),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
logger.info(f"Webhook {webhook.id} sent successfully")
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=500,
|
||||
response_headers="",
|
||||
response_body=str(e),
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
logger.error(f"Webhook {webhook.id} failed with error: {e}")
|
||||
# Retry logic
|
||||
|
||||
@@ -5,11 +5,9 @@ import time
|
||||
import uuid
|
||||
from typing import Dict
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -27,13 +25,6 @@ from plane.db.models import (
|
||||
IssueLabel,
|
||||
IssueSequence,
|
||||
IssueActivity,
|
||||
Page,
|
||||
ProjectPage,
|
||||
Cycle,
|
||||
Module,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueView,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
@@ -125,33 +116,13 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
user_id=workspace_member["member_id"],
|
||||
workspace_id=workspace.id,
|
||||
display_filters={
|
||||
"layout": "list",
|
||||
"calendar": {"layout": "month", "show_weekends": False},
|
||||
"group_by": "state",
|
||||
"group_by": None,
|
||||
"order_by": "sort_order",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"sub_group_by": None,
|
||||
"show_empty_groups": True,
|
||||
},
|
||||
display_properties={
|
||||
"key": True,
|
||||
"link": True,
|
||||
"cycle": False,
|
||||
"state": True,
|
||||
"labels": False,
|
||||
"modules": False,
|
||||
"assignee": True,
|
||||
"due_date": False,
|
||||
"estimate": True,
|
||||
"priority": True,
|
||||
"created_on": True,
|
||||
"issue_type": True,
|
||||
"start_date": False,
|
||||
"updated_on": True,
|
||||
"customer_count": True,
|
||||
"sub_issue_count": False,
|
||||
"attachment_count": False,
|
||||
"customer_request_count": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
@@ -234,8 +205,6 @@ def create_project_issues(
|
||||
project_map: Dict[int, uuid.UUID],
|
||||
states_map: Dict[int, uuid.UUID],
|
||||
labels_map: Dict[int, uuid.UUID],
|
||||
cycles_map: Dict[int, uuid.UUID],
|
||||
module_map: Dict[int, uuid.UUID],
|
||||
) -> None:
|
||||
"""Creates issues and their associated records for each project.
|
||||
|
||||
@@ -265,8 +234,6 @@ def create_project_issues(
|
||||
labels = issue_seed.pop("labels")
|
||||
project_id = issue_seed.pop("project_id")
|
||||
state_id = issue_seed.pop("state_id")
|
||||
cycle_id = issue_seed.pop("cycle_id")
|
||||
module_ids = issue_seed.pop("module_ids")
|
||||
|
||||
issue = Issue.objects.create(
|
||||
**issue_seed,
|
||||
@@ -292,7 +259,6 @@ def create_project_issues(
|
||||
epoch=time.time(),
|
||||
)
|
||||
|
||||
# Create issue labels
|
||||
for label_id in labels:
|
||||
IssueLabel.objects.create(
|
||||
issue=issue,
|
||||
@@ -302,172 +268,10 @@ def create_project_issues(
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
# Create cycle issues
|
||||
if cycle_id:
|
||||
CycleIssue.objects.create(
|
||||
issue=issue,
|
||||
cycle_id=cycles_map[cycle_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
# Create module issues
|
||||
if module_ids:
|
||||
for module_id in module_ids:
|
||||
ModuleIssue.objects.create(
|
||||
issue=issue,
|
||||
module_id=module_map[module_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
"""Creates pages for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
page_seeds = read_seed_file("pages.json")
|
||||
|
||||
if not page_seeds:
|
||||
return
|
||||
|
||||
for page_seed in page_seeds:
|
||||
page_id = page_seed.pop("id")
|
||||
|
||||
page = Page.objects.create(
|
||||
workspace_id=workspace.id,
|
||||
is_global=False,
|
||||
access=page_seed.get("access", Page.PUBLIC_ACCESS),
|
||||
name=page_seed.get("name"),
|
||||
description=page_seed.get("description", {}),
|
||||
description_html=page_seed.get("description_html", "<p></p>"),
|
||||
description_binary=page_seed.get("description_binary", None),
|
||||
description_stripped=page_seed.get("description_stripped", None),
|
||||
created_by_id=workspace.created_by_id,
|
||||
updated_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
|
||||
if page_seed.get("project_id") and page_seed.get("type") == "PROJECT":
|
||||
ProjectPage.objects.create(
|
||||
workspace_id=workspace.id,
|
||||
project_id=project_map[page_seed.get("project_id")],
|
||||
page_id=page.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
updated_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
|
||||
# Create cycles
|
||||
cycle_seeds = read_seed_file("cycles.json")
|
||||
if not cycle_seeds:
|
||||
return
|
||||
|
||||
cycle_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
for cycle_seed in cycle_seeds:
|
||||
cycle_id = cycle_seed.pop("id")
|
||||
project_id = cycle_seed.pop("project_id")
|
||||
type = cycle_seed.pop("type")
|
||||
|
||||
if type == "CURRENT":
|
||||
start_date = timezone.now()
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
if type == "UPCOMING":
|
||||
# Get the last cycle
|
||||
last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first()
|
||||
if last_cycle:
|
||||
start_date = last_cycle.end_date + timedelta(days=1)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
else:
|
||||
start_date = timezone.now() + timedelta(days=14)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
cycle = Cycle.objects.create(
|
||||
**cycle_seed,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
cycle_map[cycle_id] = cycle.id
|
||||
logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created")
|
||||
return cycle_map
|
||||
|
||||
|
||||
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
"""Creates modules for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
module_seeds = read_seed_file("modules.json")
|
||||
if not module_seeds:
|
||||
return
|
||||
|
||||
module_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
for index, module_seed in enumerate(module_seeds):
|
||||
module_id = module_seed.pop("id")
|
||||
project_id = module_seed.pop("project_id")
|
||||
|
||||
start_date = timezone.now() + timedelta(days=index * 2)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
module = Module.objects.create(
|
||||
**module_seed,
|
||||
start_date=start_date,
|
||||
target_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
module_map[module_id] = module.id
|
||||
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
|
||||
return module_map
|
||||
|
||||
|
||||
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
"""Creates views for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
|
||||
view_seeds = read_seed_file("views.json")
|
||||
if not view_seeds:
|
||||
return
|
||||
|
||||
for view_seed in view_seeds:
|
||||
project_id = view_seed.pop("project_id")
|
||||
IssueView.objects.create(
|
||||
**view_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_seed(workspace_id: uuid.UUID) -> None:
|
||||
"""Seeds a new workspace with initial project data.
|
||||
@@ -495,20 +299,8 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
|
||||
# Create project labels
|
||||
label_map = create_project_labels(workspace, project_map)
|
||||
|
||||
# Create project cycles
|
||||
cycle_map = create_cycles(workspace, project_map)
|
||||
|
||||
# Create project modules
|
||||
module_map = create_modules(workspace, project_map)
|
||||
|
||||
# create project issues
|
||||
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
|
||||
|
||||
# create project views
|
||||
create_views(workspace, project_map)
|
||||
|
||||
# create project pages
|
||||
create_pages(workspace, project_map)
|
||||
create_project_issues(workspace, project_map, state_map, label_map)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.24 on 2025-10-08 10:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import plane.db.models.user
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0107_migrate_filters_to_rich_filters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='tour_completed_features',
|
||||
field=models.JSONField(default=plane.db.models.user.get_default_tour_completed_features),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exporterhistory',
|
||||
name='url',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -42,7 +42,7 @@ class ExporterHistory(BaseModel):
|
||||
)
|
||||
reason = models.TextField(blank=True)
|
||||
key = models.TextField(blank=True)
|
||||
url = models.URLField(max_length=800, blank=True, null=True)
|
||||
url = models.TextField(blank=True, null=True)
|
||||
token = models.CharField(max_length=255, default=generate_token, unique=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -273,21 +273,6 @@ class IssueRelationChoices(models.TextChoices):
|
||||
IMPLEMENTED_BY = "implemented_by", "Implemented By"
|
||||
|
||||
|
||||
# Bidirectional relation pairs: (forward, reverse)
|
||||
# Defined after class to avoid enum metaclass conflicts
|
||||
IssueRelationChoices._RELATION_PAIRS = (
|
||||
("blocked_by", "blocking"),
|
||||
("relates_to", "relates_to"), # symmetric
|
||||
("duplicate", "duplicate"), # symmetric
|
||||
("start_before", "start_after"),
|
||||
("finish_before", "finish_after"),
|
||||
("implemented_by", "implements"),
|
||||
)
|
||||
|
||||
# Generate reverse mapping from pairs
|
||||
IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS}
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE)
|
||||
related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE)
|
||||
|
||||
@@ -34,6 +34,20 @@ def get_mobile_default_onboarding():
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
def get_default_tour_completed_features():
|
||||
return {
|
||||
"workspace_features": {
|
||||
"projects": False
|
||||
},
|
||||
"project_features": {
|
||||
"workitems": False,
|
||||
"cycles": False,
|
||||
"modules": False,
|
||||
"views": False,
|
||||
"pages": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
@@ -214,6 +228,9 @@ class Profile(TimeAuditModel):
|
||||
goals = models.JSONField(default=dict)
|
||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||
|
||||
# product tour features
|
||||
tour_completed_features = models.JSONField(default=get_default_tour_completed_features)
|
||||
|
||||
# marketing
|
||||
has_marketing_email_consent = models.BooleanField(default=False)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.request import Request
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
api_logger = logging.getLogger("plane.api.request")
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from django.core.exceptions import RequestDataTooBig
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
class RequestBodySizeLimitMiddleware:
|
||||
"""
|
||||
Middleware to catch RequestDataTooBig exceptions and return
|
||||
413 Request Entity Too Large instead of 400 Bad Request.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
_ = request.body
|
||||
except RequestDataTooBig:
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": "REQUEST_BODY_TOO_LARGE",
|
||||
"detail": "The size of the request body exceeds the maximum allowed size.",
|
||||
},
|
||||
status=413,
|
||||
)
|
||||
|
||||
# If body size is OK, continue with the request
|
||||
return self.get_response(request)
|
||||
@@ -1,18 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Cycle 1: Getting Started with Plane",
|
||||
"project_id": 1,
|
||||
"sort_order": 1,
|
||||
"timezone": "UTC",
|
||||
"type": "CURRENT"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Cycle 2: Collaboration & Customization",
|
||||
"project_id": 1,
|
||||
"sort_order": 2,
|
||||
"timezone": "UTC",
|
||||
"type": "UPCOMING"
|
||||
}
|
||||
]
|
||||
@@ -6,12 +6,10 @@
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.</p><p class=\"editor-paragraph-block\">Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.</p><p class=\"editor-paragraph-block\">First thing to try</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look in the <strong>Properties</strong> section below where it says <strong>State: Todo</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on it and change it to <strong>Done</strong> from the dropdown. Alternatively, you can drag and drop the card to the Done column.</p></li></ol>",
|
||||
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
|
||||
"sort_order": 1000,
|
||||
"state_id": 4,
|
||||
"state_id": 3,
|
||||
"labels": [],
|
||||
"priority": "urgent",
|
||||
"project_id": 1,
|
||||
"cycle_id": 1,
|
||||
"module_ids": [1]
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -21,10 +19,8 @@
|
||||
"sort_order": 2000,
|
||||
"state_id": 2,
|
||||
"labels": [2],
|
||||
"priority": "high",
|
||||
"project_id": 1,
|
||||
"cycle_id": 1,
|
||||
"module_ids": [1]
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -35,10 +31,8 @@
|
||||
"sort_order": 3000,
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "high",
|
||||
"project_id": 1,
|
||||
"cycle_id": 1,
|
||||
"module_ids": [1, 2]
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -47,12 +41,10 @@
|
||||
"description_html": "<p class=\"editor-paragraph-block\">A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.</p><p class=\"editor-paragraph-block\">Ready to add something to your project's to-do list? Here's how:</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add work item</strong> button in the top-right corner of the Work Items page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/41.png\" width=\"1085.380859375px\" height=\"482.53758375605696px\" id=\"ba055bc3-4162-4750-9ad4-9434fc0e7121\" aspectratio=\"2.249318801089918\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Give your task a clear title and add any details in the description.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Set up the essentials:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Assign it to a team member (or yourself!)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose a priority level</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add start and due dates if there's a timeline</p></li></ul></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Save time by using the keyboard shortcut <strong>C</strong> from anywhere in your project to quickly create a new work item!</p></div><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to dive deeper into all the things you can do with work items? Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/overview\">documentation</a>.</p>",
|
||||
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
|
||||
"sort_order": 4000,
|
||||
"state_id": 3,
|
||||
"state_id": 1,
|
||||
"labels": [2],
|
||||
"priority": "high",
|
||||
"project_id": 1,
|
||||
"cycle_id": 1,
|
||||
"module_ids": [1, 2]
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
@@ -61,12 +53,10 @@
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!</p><image-component src=\"https://media.docs.plane.so/seed_assets/51.png\" aspectratio=\"4.489130434782608\"></image-component><h2 class=\"editor-heading-block\">Switch between layouts</h2><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look at the top toolbar in your project. You'll see several layout icons.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click any of these icons to instantly switch between layouts.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/layouts\"><strong>Layouts</strong></a> for more info.</p></div><h2 class=\"editor-heading-block\">Filter and display options</h2><p class=\"editor-paragraph-block\">Need to focus on specific work?</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Filters</strong> dropdown in the toolbar. Select criteria and choose which items to show.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Display</strong> dropdown to tailor how the information appears in your layout</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Created the perfect setup? Save it for later by clicking the the <strong>Save View</strong> button.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Access saved views anytime from the <strong>Views</strong> section in your sidebar.</p></li></ol>",
|
||||
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
|
||||
"sort_order": 5000,
|
||||
"state_id": 3,
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1,
|
||||
"cycle_id": 2,
|
||||
"module_ids": [2]
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
@@ -77,10 +67,8 @@
|
||||
"sort_order": 6000,
|
||||
"state_id": 1,
|
||||
"labels": [2],
|
||||
"priority": "low",
|
||||
"project_id": 1,
|
||||
"cycle_id": 2,
|
||||
"module_ids": [2, 3]
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -92,8 +80,6 @@
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1,
|
||||
"cycle_id": 2,
|
||||
"module_ids": [2, 3]
|
||||
"project_id": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Core Workflow (System)",
|
||||
"project_id": 1,
|
||||
"sort_order": 1,
|
||||
"status": "planned",
|
||||
"description": "Manage, visualize, and track your work items across views."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Onboarding Flow (Feature)",
|
||||
"project_id": 1,
|
||||
"sort_order": 2,
|
||||
"status": "backlog",
|
||||
"description": "Everything about getting started - creating a project, inviting teammates."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Workspace Setup (Area)",
|
||||
"project_id": 1,
|
||||
"sort_order": 3,
|
||||
"status": "in-progress",
|
||||
"description": "The personalization layer - settings, labels, automations."
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Project Urgent Tasks",
|
||||
"description": "Project Urgent Tasks",
|
||||
"access": 1,
|
||||
"filters": {},
|
||||
"project_id": 1,
|
||||
"display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false},
|
||||
"display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true},
|
||||
"sort_order": 75535,
|
||||
"rich_filters": {"priority__in": "urgent"}
|
||||
}
|
||||
]
|
||||
@@ -62,7 +62,6 @@ MIDDLEWARE = [
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"plane.middleware.request_body_size.RequestBodySizeLimitMiddleware",
|
||||
"plane.middleware.logger.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.RequestLoggerMiddleware",
|
||||
]
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
# 📊 Exporters
|
||||
|
||||
A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX).
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The exporters module provides a schema-based approach to exporting data with support for:
|
||||
|
||||
- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel)
|
||||
- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField
|
||||
- **⚡ Custom transformations**: Field-level transformations and custom preparer methods
|
||||
- **🔗 Dotted path notation**: Easy access to nested attributes and related models
|
||||
- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField
|
||||
|
||||
# Define a schema
|
||||
class UserExportSchema(ExportSchema):
|
||||
name = StringField(source="username", label="User Name")
|
||||
email = StringField(source="email", label="Email Address")
|
||||
posts_count = NumberField(label="Total Posts")
|
||||
|
||||
def prepare_posts_count(self, obj):
|
||||
return obj.posts.count()
|
||||
|
||||
# Export data - just pass the queryset!
|
||||
users = User.objects.all()
|
||||
exporter = Exporter(format_type="csv", schema_class=UserExportSchema)
|
||||
filename, content = exporter.export("users_export", users)
|
||||
```
|
||||
|
||||
### Exporting Issues
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
|
||||
# Get issues with prefetched relations
|
||||
issues = Issue.objects.filter(project_id=project_id).prefetch_related(
|
||||
'assignee_details',
|
||||
'label_details',
|
||||
'issue_module',
|
||||
# ... other relations
|
||||
)
|
||||
|
||||
# Export as XLSX - pass the queryset directly!
|
||||
exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues", issues)
|
||||
|
||||
# Export with custom fields only
|
||||
exporter = Exporter(format_type="json", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"])
|
||||
```
|
||||
|
||||
### Exporting Multiple Projects Separately
|
||||
|
||||
```python
|
||||
# Export each project to a separate file
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export(f"issues-{project_id}", project_issues)
|
||||
# Save or upload the file
|
||||
```
|
||||
|
||||
## 📝 Schema Definition
|
||||
|
||||
### Field Types
|
||||
|
||||
#### 📝 StringField
|
||||
|
||||
Converts values to strings.
|
||||
|
||||
```python
|
||||
name = StringField(source="name", label="Name", default="N/A")
|
||||
```
|
||||
|
||||
#### 🔢 NumberField
|
||||
|
||||
Handles numeric values (int, float).
|
||||
|
||||
```python
|
||||
count = NumberField(source="items_count", label="Count", default=0)
|
||||
```
|
||||
|
||||
#### 📅 DateField
|
||||
|
||||
Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024").
|
||||
|
||||
```python
|
||||
start_date = DateField(source="start_date", label="Start Date")
|
||||
```
|
||||
|
||||
#### ⏰ DateTimeField
|
||||
|
||||
Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`.
|
||||
|
||||
```python
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
```
|
||||
|
||||
#### ✅ BooleanField
|
||||
|
||||
Converts values to boolean.
|
||||
|
||||
```python
|
||||
is_active = BooleanField(source="is_active", label="Active", default=False)
|
||||
```
|
||||
|
||||
#### 📋 ListField
|
||||
|
||||
Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays.
|
||||
|
||||
```python
|
||||
tags = ListField(source="tags", label="Tags")
|
||||
assignees = ListField(label="Assignees") # Custom preparer can populate this
|
||||
```
|
||||
|
||||
#### 🗂️ JSONField
|
||||
|
||||
Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects.
|
||||
|
||||
```python
|
||||
metadata = JSONField(source="metadata", label="Metadata")
|
||||
comments = JSONField(label="Comments")
|
||||
```
|
||||
|
||||
### ⚙️ Field Parameters
|
||||
|
||||
All field types support these parameters:
|
||||
|
||||
- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`)
|
||||
- **`default`**: Default value when field is None
|
||||
- **`label`**: Display name in export headers
|
||||
|
||||
### 🔗 Dotted Path Notation
|
||||
|
||||
Access nested attributes using dot notation:
|
||||
|
||||
```python
|
||||
project_name = StringField(source="project.name", label="Project")
|
||||
owner_email = StringField(source="created_by.email", label="Owner Email")
|
||||
```
|
||||
|
||||
### 🎯 Custom Preparers
|
||||
|
||||
For complex logic, define `prepare_{field_name}` methods:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
assignees = ListField(label="Assignees")
|
||||
|
||||
def prepare_assignees(self, obj):
|
||||
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
|
||||
```
|
||||
|
||||
Preparers take precedence over field definitions.
|
||||
|
||||
### ⚡ Custom Transformations with Preparer Methods
|
||||
|
||||
For any custom logic or transformations, use `prepare_<field_name>` methods:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
name = StringField(source="name", label="Name (Uppercase)")
|
||||
status = StringField(label="Status")
|
||||
|
||||
def prepare_name(self, obj):
|
||||
"""Transform the name field to uppercase."""
|
||||
return obj.name.upper() if obj.name else ""
|
||||
|
||||
def prepare_status(self, obj):
|
||||
"""Compute status based on model state."""
|
||||
return "Active" if obj.is_active else "Inactive"
|
||||
```
|
||||
|
||||
## 📦 Export Formats
|
||||
|
||||
### 📊 CSV Format
|
||||
|
||||
- Fields are quoted with `QUOTE_ALL`
|
||||
- Lists are joined with `", "` (customizable with `list_joiner` option)
|
||||
- JSON objects are serialized as JSON strings
|
||||
- File extension: `.csv`
|
||||
|
||||
```python
|
||||
exporter = Exporter(
|
||||
format_type="csv",
|
||||
schema_class=MySchema,
|
||||
options={"list_joiner": "; "} # Custom separator
|
||||
)
|
||||
```
|
||||
|
||||
### 📋 JSON Format
|
||||
|
||||
- Lists remain as arrays
|
||||
- Objects remain as nested structures
|
||||
- Preserves data types
|
||||
- File extension: `.json`
|
||||
|
||||
```python
|
||||
exporter = Exporter(format_type="json", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", records)
|
||||
# content is a JSON string: '[{"field": "value"}, ...]'
|
||||
```
|
||||
|
||||
### 📗 XLSX Format
|
||||
|
||||
- Creates Excel-compatible files using openpyxl
|
||||
- Lists are joined with `", "` (customizable with `list_joiner` option)
|
||||
- JSON objects are serialized as JSON strings
|
||||
- File extension: `.xlsx`
|
||||
- Returns binary content (bytes)
|
||||
|
||||
```python
|
||||
exporter = Exporter(format_type="xlsx", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", records)
|
||||
# content is bytes
|
||||
```
|
||||
|
||||
## 🔧 Advanced Usage
|
||||
|
||||
### 📦 Using Context for Pre-fetched Data
|
||||
|
||||
Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema:
|
||||
|
||||
```python
|
||||
class MySchema(ExportSchema):
|
||||
attachment_count = NumberField(label="Attachments")
|
||||
|
||||
def prepare_attachment_count(self, obj):
|
||||
attachments_dict = self.context.get("attachments_dict", {})
|
||||
return len(attachments_dict.get(obj.id, []))
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
"""Pre-fetch all attachments in one query."""
|
||||
attachments_dict = get_attachments_dict(queryset)
|
||||
return {"attachments_dict": attachments_dict}
|
||||
|
||||
# The Exporter automatically uses get_context_data() when serializing
|
||||
queryset = MyModel.objects.all()
|
||||
exporter = Exporter(format_type="csv", schema_class=MySchema)
|
||||
filename, content = exporter.export("data", queryset)
|
||||
```
|
||||
|
||||
### 🔌 Registering Custom Formatters
|
||||
|
||||
Add support for new export formats:
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, BaseFormatter
|
||||
|
||||
class XMLFormatter(BaseFormatter):
|
||||
def format(self, filename, records, schema_class, options=None):
|
||||
# Implementation
|
||||
return (f"{filename}.xml", xml_content)
|
||||
|
||||
# Register the formatter
|
||||
Exporter.register_formatter("xml", XMLFormatter)
|
||||
|
||||
# Use it
|
||||
exporter = Exporter(format_type="xml", schema_class=MySchema)
|
||||
```
|
||||
|
||||
### ✅ Checking Available Formats
|
||||
|
||||
```python
|
||||
formats = Exporter.get_available_formats()
|
||||
# Returns: ['csv', 'json', 'xlsx']
|
||||
```
|
||||
|
||||
### 🔍 Filtering Fields
|
||||
|
||||
Pass a `fields` parameter to export only specific fields:
|
||||
|
||||
```python
|
||||
# Export only specific fields
|
||||
exporter = Exporter(format_type="csv", schema_class=MySchema)
|
||||
filename, content = exporter.export(
|
||||
"filtered_data",
|
||||
queryset,
|
||||
fields=["id", "name", "email"]
|
||||
)
|
||||
```
|
||||
|
||||
### 🎯 Extending Schemas
|
||||
|
||||
Create extended schemas by inheriting from existing ones and overriding `get_context_data()`:
|
||||
|
||||
```python
|
||||
class ExtendedIssueExportSchema(IssueExportSchema):
|
||||
custom_field = JSONField(label="Custom Data")
|
||||
|
||||
def prepare_custom_field(self, obj):
|
||||
# Use pre-fetched data from context
|
||||
return self.context.get("custom_data", {}).get(obj.id, {})
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
# Get parent context (attachments, etc.)
|
||||
context = super().get_context_data(queryset)
|
||||
|
||||
# Add your custom pre-fetched data
|
||||
context["custom_data"] = fetch_custom_data(queryset)
|
||||
|
||||
return context
|
||||
```
|
||||
|
||||
### 💾 Manual Serialization
|
||||
|
||||
If you need to serialize data without exporting, you can use the schema directly:
|
||||
|
||||
```python
|
||||
# Serialize a queryset to a list of dicts
|
||||
data = MySchema.serialize_queryset(queryset, fields=["id", "name"])
|
||||
|
||||
# Or serialize a single object
|
||||
schema = MySchema()
|
||||
obj_data = schema.serialize(obj)
|
||||
```
|
||||
|
||||
## 💡 Example: IssueExportSchema
|
||||
|
||||
The `IssueExportSchema` demonstrates a complete implementation:
|
||||
|
||||
```python
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
|
||||
# Simple export - just pass the queryset!
|
||||
issues = Issue.objects.filter(project_id=project_id)
|
||||
exporter = Exporter(format_type="csv", schema_class=IssueExportSchema)
|
||||
filename, content = exporter.export("issues", issues)
|
||||
|
||||
# Export specific fields only
|
||||
filename, content = exporter.export(
|
||||
"issues_filtered",
|
||||
issues,
|
||||
fields=["id", "name", "state_name", "assignees", "labels"]
|
||||
)
|
||||
|
||||
# Export multiple projects to separate files
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
filename, content = exporter.export(f"issues-{project_id}", project_issues)
|
||||
# Save or upload each file
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- 🔗 Access to related models via dotted paths
|
||||
- 🎯 Custom preparers for complex fields
|
||||
- 📎 Context-based attachment handling via `get_context_data()`
|
||||
- 📋 List and JSON field handling
|
||||
- 📅 Date/datetime formatting
|
||||
|
||||
## ✨ Best Practices
|
||||
|
||||
1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset):
|
||||
return {
|
||||
"attachments": get_attachments_dict(queryset),
|
||||
"comments": get_comments_dict(queryset),
|
||||
}
|
||||
```
|
||||
|
||||
2. **🏷️ Use Labels**: Provide descriptive labels for better export headers:
|
||||
|
||||
```python
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
```
|
||||
|
||||
3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None:
|
||||
|
||||
```python
|
||||
count = NumberField(source="count", default=0)
|
||||
```
|
||||
|
||||
4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations:
|
||||
|
||||
```python
|
||||
def prepare_assignees(self, obj):
|
||||
return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details]
|
||||
```
|
||||
|
||||
5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization:
|
||||
|
||||
```python
|
||||
# Good - Exporter handles serialization
|
||||
exporter.export("data", queryset)
|
||||
|
||||
# Avoid - Manual serialization unless needed
|
||||
data = MySchema.serialize_queryset(queryset)
|
||||
exporter.export("data", data)
|
||||
```
|
||||
|
||||
6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data:
|
||||
|
||||
```python
|
||||
# Good - efficient, only serializes what's needed
|
||||
for project_id in project_ids:
|
||||
project_issues = issues.filter(project_id=project_id)
|
||||
exporter.export(f"project-{project_id}", project_issues)
|
||||
|
||||
# Avoid - serializes all data upfront
|
||||
all_data = MySchema.serialize_queryset(issues)
|
||||
for project_id in project_ids:
|
||||
project_data = [d for d in all_data if d['project_id'] == project_id]
|
||||
exporter.export(f"project-{project_id}", project_data)
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### 📊 Exporter
|
||||
|
||||
**`__init__(format_type, schema_class, options=None)`**
|
||||
|
||||
- `format_type`: Export format ('csv', 'json', 'xlsx')
|
||||
- `schema_class`: Schema class defining fields
|
||||
- `options`: Optional dict of format-specific options
|
||||
|
||||
**`export(filename, data, fields=None)`**
|
||||
|
||||
- `filename`: Filename without extension
|
||||
- `data`: Django QuerySet or list of dicts
|
||||
- `fields`: Optional list of field names to include
|
||||
- Returns: `(filename_with_extension, content)`
|
||||
- `content` is str for CSV/JSON, bytes for XLSX
|
||||
|
||||
**`get_available_formats()`** (class method)
|
||||
|
||||
- Returns: List of available format types
|
||||
|
||||
**`register_formatter(format_type, formatter_class)`** (class method)
|
||||
|
||||
- Register a custom formatter
|
||||
|
||||
### 📝 ExportSchema
|
||||
|
||||
**`__init__(context=None)`**
|
||||
|
||||
- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data
|
||||
|
||||
**`serialize(obj, fields=None)`**
|
||||
|
||||
- Returns: Dict of serialized field values for a single object
|
||||
|
||||
**`serialize_queryset(queryset, fields=None)`** (class method)
|
||||
|
||||
- `queryset`: QuerySet of objects to serialize
|
||||
- `fields`: Optional list of field names to include
|
||||
- Returns: List of dicts with serialized data
|
||||
|
||||
**`get_context_data(queryset)`** (class method)
|
||||
|
||||
- Override to pre-fetch related data for the queryset
|
||||
- Returns: Dict of context data
|
||||
|
||||
### 🔧 ExportField
|
||||
|
||||
Base class for all field types. Subclass to create custom field types.
|
||||
|
||||
**`get_value(obj, context)`**
|
||||
|
||||
- Returns: Formatted value for the field
|
||||
|
||||
**`_format_value(raw)`**
|
||||
|
||||
- Override in subclasses for type-specific formatting
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```python
|
||||
# Test exporting a queryset
|
||||
queryset = MyModel.objects.all()
|
||||
exporter = Exporter(format_type="json", schema_class=MySchema)
|
||||
filename, content = exporter.export("test", queryset)
|
||||
assert filename == "test.json"
|
||||
assert isinstance(content, str)
|
||||
|
||||
# Test with field filtering
|
||||
filename, content = exporter.export("test", queryset, fields=["id", "name"])
|
||||
data = json.loads(content)
|
||||
assert all(set(item.keys()) == {"id", "name"} for item in data)
|
||||
|
||||
# Test manual serialization
|
||||
data = MySchema.serialize_queryset(queryset)
|
||||
assert len(data) == queryset.count()
|
||||
```
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Export utilities for various data formats."""
|
||||
|
||||
from .exporter import Exporter
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
from .schemas import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportField,
|
||||
ExportSchema,
|
||||
IssueExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core Exporter
|
||||
"Exporter",
|
||||
# Schemas
|
||||
"ExportSchema",
|
||||
"ExportField",
|
||||
"StringField",
|
||||
"NumberField",
|
||||
"DateField",
|
||||
"DateTimeField",
|
||||
"BooleanField",
|
||||
"ListField",
|
||||
"JSONField",
|
||||
# Formatters
|
||||
"BaseFormatter",
|
||||
"CSVFormatter",
|
||||
"JSONFormatter",
|
||||
"XLSXFormatter",
|
||||
# Issue Schema
|
||||
"IssueExportSchema",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
from typing import Any, Dict, List, Type, Union
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
|
||||
|
||||
class Exporter:
|
||||
"""Generic exporter class that handles data exports using different formatters."""
|
||||
|
||||
# Available formatters
|
||||
FORMATTERS = {
|
||||
"csv": CSVFormatter,
|
||||
"json": JSONFormatter,
|
||||
"xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None):
|
||||
"""Initialize exporter with specified format type and schema.
|
||||
|
||||
Args:
|
||||
format_type: The export format (csv, json, xlsx)
|
||||
schema_class: The schema class to use for field definitions
|
||||
options: Optional formatting options
|
||||
"""
|
||||
if format_type not in self.FORMATTERS:
|
||||
raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
|
||||
|
||||
self.format_type = format_type
|
||||
self.schema_class = schema_class
|
||||
self.formatter = self.FORMATTERS[format_type]()
|
||||
self.options = options or {}
|
||||
|
||||
def export(
|
||||
self,
|
||||
filename: str,
|
||||
data: Union[QuerySet, List[dict]],
|
||||
fields: List[str] = None,
|
||||
) -> tuple[str, str | bytes]:
|
||||
"""Export data using the configured formatter and return (filename, content).
|
||||
|
||||
Args:
|
||||
filename: The filename for the export (without extension)
|
||||
data: Either a Django QuerySet or a list of already-serialized dicts
|
||||
fields: Optional list of field names to include in export
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
"""
|
||||
# Serialize the queryset if needed
|
||||
if isinstance(data, QuerySet):
|
||||
records = self.schema_class.serialize_queryset(data, fields=fields)
|
||||
else:
|
||||
# Already serialized data
|
||||
records = data
|
||||
|
||||
# Merge fields into options for the formatter
|
||||
format_options = {**self.options}
|
||||
if fields:
|
||||
format_options["fields"] = fields
|
||||
|
||||
return self.formatter.format(filename, records, self.schema_class, format_options)
|
||||
|
||||
@classmethod
|
||||
def get_available_formats(cls) -> List[str]:
|
||||
"""Get list of available export formats."""
|
||||
return list(cls.FORMATTERS.keys())
|
||||
|
||||
@classmethod
|
||||
def register_formatter(cls, format_type: str, formatter_class: type) -> None:
|
||||
"""Register a new formatter for a format type."""
|
||||
cls.FORMATTERS[format_type] = formatter_class
|
||||
@@ -1,199 +0,0 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
|
||||
class BaseFormatter:
|
||||
"""Base class for export formatters."""
|
||||
|
||||
def format(
|
||||
self,
|
||||
filename: str,
|
||||
records: List[dict],
|
||||
schema_class: Type,
|
||||
options: Dict[str, Any] | None = None,
|
||||
) -> tuple[str, str | bytes]:
|
||||
"""Format records for export.
|
||||
|
||||
Args:
|
||||
filename: The filename for the export (without extension)
|
||||
records: List of records to export
|
||||
schema_class: Schema class to extract field order and labels
|
||||
options: Optional formatting options
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]:
|
||||
"""Extract field order and labels from schema.
|
||||
|
||||
Args:
|
||||
schema_class: Schema class with field definitions
|
||||
|
||||
Returns:
|
||||
Tuple of (field_order, field_labels)
|
||||
"""
|
||||
if not hasattr(schema_class, "_declared_fields"):
|
||||
raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute")
|
||||
|
||||
# Get order and labels from schema
|
||||
field_order = list(schema_class._declared_fields.keys())
|
||||
field_labels = {
|
||||
name: field.label if field.label else name.replace("_", " ").title()
|
||||
for name, field in schema_class._declared_fields.items()
|
||||
}
|
||||
|
||||
return field_order, field_labels
|
||||
|
||||
|
||||
class CSVFormatter(BaseFormatter):
|
||||
"""Formatter for CSV exports."""
|
||||
|
||||
@staticmethod
|
||||
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
|
||||
"""Format a field value for CSV output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
# For complex objects, serialize as JSON
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
def _generate_table_row(
|
||||
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> List[str]:
|
||||
"""Generate a CSV row from a record."""
|
||||
opts = options or {}
|
||||
list_joiner = opts.get("list_joiner", ", ")
|
||||
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
|
||||
|
||||
def _create_csv_file(self, data: List[List[str]]) -> str:
|
||||
"""Create CSV file content from row data."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
for row in data:
|
||||
writer.writerow(row)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
if not records:
|
||||
return (f"{filename}.csv", "")
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
header = [field_labels[field] for field in field_order]
|
||||
|
||||
rows = [header]
|
||||
for record in records:
|
||||
row = self._generate_table_row(record, field_order, options)
|
||||
rows.append(row)
|
||||
content = self._create_csv_file(rows)
|
||||
return (f"{filename}.csv", content)
|
||||
|
||||
|
||||
class JSONFormatter(BaseFormatter):
|
||||
"""Formatter for JSON exports."""
|
||||
|
||||
def _generate_json_row(
|
||||
self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> dict:
|
||||
"""Generate a JSON object from a record.
|
||||
|
||||
Preserves data types - lists stay as arrays, dicts stay as objects.
|
||||
"""
|
||||
return {field_labels[field]: record.get(field) for field in field_order if field in record}
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
if not records:
|
||||
return (f"{filename}.json", "[]")
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
rows: List[dict] = []
|
||||
for record in records:
|
||||
row = self._generate_json_row(record, field_labels, field_order, options)
|
||||
rows.append(row)
|
||||
content = json.dumps(rows)
|
||||
return (f"{filename}.json", content)
|
||||
|
||||
|
||||
class XLSXFormatter(BaseFormatter):
|
||||
"""Formatter for XLSX (Excel) exports."""
|
||||
|
||||
@staticmethod
|
||||
def _format_field_value(value: Any, list_joiner: str = ", ") -> str:
|
||||
"""Format a field value for XLSX output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
# For complex objects, serialize as JSON
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
def _generate_table_row(
|
||||
self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None
|
||||
) -> List[str]:
|
||||
"""Generate an XLSX row from a record."""
|
||||
opts = options or {}
|
||||
list_joiner = opts.get("list_joiner", ", ")
|
||||
return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order]
|
||||
|
||||
def _create_xlsx_file(self, data: List[List[str]]) -> bytes:
|
||||
"""Create XLSX file content from row data."""
|
||||
wb = Workbook()
|
||||
sh = wb.active
|
||||
for row in data:
|
||||
sh.append(row)
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
out.seek(0)
|
||||
return out.getvalue()
|
||||
|
||||
def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]:
|
||||
if not records:
|
||||
# Create empty workbook
|
||||
content = self._create_xlsx_file([])
|
||||
return (f"{filename}.xlsx", content)
|
||||
|
||||
# Get field order and labels from schema
|
||||
field_order, field_labels = self._get_field_info(schema_class)
|
||||
|
||||
# Filter to requested fields if specified
|
||||
opts = options or {}
|
||||
requested_fields = opts.get("fields")
|
||||
if requested_fields:
|
||||
field_order = [f for f in field_order if f in requested_fields]
|
||||
|
||||
header = [field_labels[field] for field in field_order]
|
||||
|
||||
rows = [header]
|
||||
for record in records:
|
||||
row = self._generate_table_row(record, field_order, options)
|
||||
rows.append(row)
|
||||
content = self._create_xlsx_file(rows)
|
||||
return (f"{filename}.xlsx", content)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Export schemas for various data types."""
|
||||
|
||||
from .base import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportField,
|
||||
ExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
from .issue import IssueExportSchema
|
||||
|
||||
__all__ = [
|
||||
# Base field types
|
||||
"ExportField",
|
||||
"StringField",
|
||||
"NumberField",
|
||||
"DateField",
|
||||
"DateTimeField",
|
||||
"BooleanField",
|
||||
"ListField",
|
||||
"JSONField",
|
||||
# Base schema
|
||||
"ExportSchema",
|
||||
# Issue schema
|
||||
"IssueExportSchema",
|
||||
]
|
||||
@@ -1,234 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportField:
|
||||
"""Base export field class for generic fields."""
|
||||
|
||||
source: Optional[str] = None
|
||||
default: Any = ""
|
||||
label: Optional[str] = None # Display name for export headers
|
||||
|
||||
def get_value(self, obj: Any, context: Dict[str, Any]) -> Any:
|
||||
raw: Any
|
||||
if self.source:
|
||||
raw = self._resolve_dotted_path(obj, self.source)
|
||||
else:
|
||||
raw = obj
|
||||
|
||||
return self._format_value(raw)
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
"""Format the raw value. Override in subclasses for type-specific formatting."""
|
||||
return raw if raw is not None else self.default
|
||||
|
||||
def _resolve_dotted_path(self, obj: Any, path: str) -> Any:
|
||||
current = obj
|
||||
for part in path.split("."):
|
||||
if current is None:
|
||||
return None
|
||||
if hasattr(current, part):
|
||||
current = getattr(current, part)
|
||||
elif isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringField(ExportField):
|
||||
"""Export field for string values."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateField(ExportField):
|
||||
"""Export field for date values with automatic conversion."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Convert date to formatted string
|
||||
if hasattr(raw, "strftime"):
|
||||
return raw.strftime("%a, %d %b %Y")
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateTimeField(ExportField):
|
||||
"""Export field for datetime values with automatic conversion."""
|
||||
|
||||
default: str = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> str:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Convert datetime to formatted string
|
||||
if hasattr(raw, "strftime"):
|
||||
return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
return str(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberField(ExportField):
|
||||
"""Export field for numeric values."""
|
||||
|
||||
default: Any = ""
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return raw
|
||||
|
||||
|
||||
@dataclass
|
||||
class BooleanField(ExportField):
|
||||
"""Export field for boolean values."""
|
||||
|
||||
default: bool = False
|
||||
|
||||
def _format_value(self, raw: Any) -> bool:
|
||||
if raw is None:
|
||||
return self.default
|
||||
return bool(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListField(ExportField):
|
||||
"""Export field for list/array values.
|
||||
|
||||
Returns the list as-is by default. The formatter will handle conversion to strings
|
||||
when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array).
|
||||
"""
|
||||
|
||||
default: Optional[List] = field(default_factory=list)
|
||||
|
||||
def _format_value(self, raw: Any) -> List[Any]:
|
||||
if raw is None:
|
||||
return self.default if self.default is not None else []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return list(raw)
|
||||
return [raw] # Wrap single items in a list
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONField(ExportField):
|
||||
"""Export field for complex JSON-serializable values (dicts, lists of dicts, etc).
|
||||
|
||||
Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter
|
||||
will handle serialization (e.g., JSON stringify).
|
||||
"""
|
||||
|
||||
default: Any = field(default_factory=dict)
|
||||
|
||||
def _format_value(self, raw: Any) -> Any:
|
||||
if raw is None:
|
||||
return self.default
|
||||
# Return as-is - should be JSON-serializable
|
||||
return raw
|
||||
|
||||
|
||||
class ExportSchemaMeta(type):
|
||||
def __new__(mcls, name, bases, attrs):
|
||||
declared: Dict[str, ExportField] = {
|
||||
key: value for key, value in list(attrs.items()) if isinstance(value, ExportField)
|
||||
}
|
||||
for key in declared.keys():
|
||||
attrs.pop(key)
|
||||
cls = super().__new__(mcls, name, bases, attrs)
|
||||
base_fields: Dict[str, ExportField] = {}
|
||||
for base in bases:
|
||||
if hasattr(base, "_declared_fields"):
|
||||
base_fields.update(base._declared_fields)
|
||||
base_fields.update(declared)
|
||||
cls._declared_fields = base_fields
|
||||
return cls
|
||||
|
||||
|
||||
class ExportSchema(metaclass=ExportSchemaMeta):
|
||||
"""Base schema for exporting data in various formats.
|
||||
|
||||
Subclasses should define fields as class attributes and can override:
|
||||
- prepare_<field_name> methods for custom field serialization
|
||||
- get_context_data() class method to pre-fetch related data for the queryset
|
||||
"""
|
||||
|
||||
def __init__(self, context: Optional[Dict[str, Any]] = None) -> None:
|
||||
self.context = context or {}
|
||||
|
||||
def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Serialize a single object.
|
||||
|
||||
Args:
|
||||
obj: The object to serialize
|
||||
fields: Optional list of field names to include. If None, all fields are serialized.
|
||||
|
||||
Returns:
|
||||
Dictionary of serialized data
|
||||
"""
|
||||
output: Dict[str, Any] = {}
|
||||
# Determine which fields to process
|
||||
fields_to_process = fields if fields else list(self._declared_fields.keys())
|
||||
|
||||
for field_name in fields_to_process:
|
||||
# Skip if field doesn't exist in schema
|
||||
if field_name not in self._declared_fields:
|
||||
continue
|
||||
|
||||
export_field = self._declared_fields[field_name]
|
||||
|
||||
# Prefer explicit preparer methods if present
|
||||
preparer = getattr(self, f"prepare_{field_name}", None)
|
||||
if callable(preparer):
|
||||
output[field_name] = preparer(obj)
|
||||
continue
|
||||
|
||||
output[field_name] = export_field.get_value(obj, self.context)
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
|
||||
"""Get context data for serialization. Override in subclasses to pre-fetch related data.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet of objects to be serialized
|
||||
|
||||
Returns:
|
||||
Dictionary of context data to be passed to the schema instance
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Serialize a queryset of objects to export data.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet of objects to serialize
|
||||
fields: Optional list of field names to include. Defaults to all fields.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing serialized data
|
||||
"""
|
||||
# Get context data (can be extended by subclasses)
|
||||
context = cls.get_context_data(queryset)
|
||||
|
||||
# Serialize each object, passing fields to only process requested fields
|
||||
schema = cls(context=context)
|
||||
data = []
|
||||
for obj in queryset:
|
||||
obj_data = schema.serialize(obj, fields=fields)
|
||||
data.append(obj_data)
|
||||
|
||||
return data
|
||||
@@ -1,211 +0,0 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.db.models import F, QuerySet
|
||||
|
||||
from plane.db.models import CycleIssue, FileAsset
|
||||
|
||||
from .base import (
|
||||
DateField,
|
||||
DateTimeField,
|
||||
ExportSchema,
|
||||
JSONField,
|
||||
ListField,
|
||||
NumberField,
|
||||
StringField,
|
||||
)
|
||||
|
||||
|
||||
def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]:
|
||||
"""Get attachments dictionary for the given issues queryset.
|
||||
|
||||
Args:
|
||||
issues_queryset: Queryset of Issue objects
|
||||
|
||||
Returns:
|
||||
Dictionary mapping issue IDs to lists of attachment IDs
|
||||
"""
|
||||
file_assets = FileAsset.objects.filter(
|
||||
issue_id__in=issues_queryset.values_list("id", flat=True),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
|
||||
|
||||
attachment_dict = defaultdict(list)
|
||||
for asset in file_assets:
|
||||
attachment_dict[asset.work_item_id].append(asset.asset_id)
|
||||
|
||||
return attachment_dict
|
||||
|
||||
|
||||
def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]:
|
||||
"""Get the last cycle for each issue in the given queryset.
|
||||
|
||||
Args:
|
||||
issues_queryset: Queryset of Issue objects
|
||||
|
||||
Returns:
|
||||
Dictionary mapping issue IDs to their last CycleIssue object
|
||||
"""
|
||||
# Fetch all cycle issues for the given issues, ordered by created_at descending
|
||||
# select_related is used to fetch cycle data in the same query
|
||||
cycle_issues = (
|
||||
CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True))
|
||||
.select_related("cycle")
|
||||
.order_by("issue_id", "-created_at")
|
||||
)
|
||||
|
||||
# Keep only the last (most recent) cycle for each issue
|
||||
last_cycles_dict = {}
|
||||
for cycle_issue in cycle_issues:
|
||||
if cycle_issue.issue_id not in last_cycles_dict:
|
||||
last_cycles_dict[cycle_issue.issue_id] = cycle_issue
|
||||
|
||||
return last_cycles_dict
|
||||
|
||||
|
||||
class IssueExportSchema(ExportSchema):
|
||||
"""Schema for exporting issue data in various formats."""
|
||||
|
||||
@staticmethod
|
||||
def _get_created_by(obj) -> str:
|
||||
"""Get the created by user for the given object."""
|
||||
try:
|
||||
if getattr(obj, "created_by", None):
|
||||
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_date(date_obj) -> str:
|
||||
"""Format date object to string."""
|
||||
if date_obj and hasattr(date_obj, "strftime"):
|
||||
return date_obj.strftime("%a, %d %b %Y")
|
||||
return ""
|
||||
|
||||
# Field definitions with display labels
|
||||
id = StringField(label="ID")
|
||||
project_identifier = StringField(source="project.identifier", label="Project Identifier")
|
||||
project_name = StringField(source="project.name", label="Project")
|
||||
project_id = StringField(source="project.id", label="Project ID")
|
||||
sequence_id = NumberField(source="sequence_id", label="Sequence ID")
|
||||
name = StringField(source="name", label="Name")
|
||||
description = StringField(source="description_stripped", label="Description")
|
||||
priority = StringField(source="priority", label="Priority")
|
||||
start_date = DateField(source="start_date", label="Start Date")
|
||||
target_date = DateField(source="target_date", label="Target Date")
|
||||
state_name = StringField(label="State")
|
||||
created_at = DateTimeField(source="created_at", label="Created At")
|
||||
updated_at = DateTimeField(source="updated_at", label="Updated At")
|
||||
completed_at = DateTimeField(source="completed_at", label="Completed At")
|
||||
archived_at = DateTimeField(source="archived_at", label="Archived At")
|
||||
module_name = ListField(label="Module Name")
|
||||
created_by = StringField(label="Created By")
|
||||
labels = ListField(label="Labels")
|
||||
comments = JSONField(label="Comments")
|
||||
estimate = StringField(label="Estimate")
|
||||
link = ListField(label="Link")
|
||||
assignees = ListField(label="Assignees")
|
||||
subscribers_count = NumberField(label="Subscribers Count")
|
||||
attachment_count = NumberField(label="Attachment Count")
|
||||
attachment_links = ListField(label="Attachment Links")
|
||||
cycle_name = StringField(label="Cycle Name")
|
||||
cycle_start_date = DateField(label="Cycle Start Date")
|
||||
cycle_end_date = DateField(label="Cycle End Date")
|
||||
parent = StringField(label="Parent")
|
||||
relations = JSONField(label="Relations")
|
||||
|
||||
def prepare_id(self, i):
|
||||
return f"{i.project.identifier}-{i.sequence_id}"
|
||||
|
||||
def prepare_state_name(self, i):
|
||||
return i.state.name if i.state else None
|
||||
|
||||
def prepare_module_name(self, i):
|
||||
return [m.module.name for m in i.issue_module.all()]
|
||||
|
||||
def prepare_created_by(self, i):
|
||||
return self._get_created_by(i)
|
||||
|
||||
def prepare_labels(self, i):
|
||||
return [label.name for label in i.labels.all()]
|
||||
|
||||
def prepare_comments(self, i):
|
||||
return [
|
||||
{
|
||||
"comment": comment.comment_stripped,
|
||||
"created_at": self._format_date(comment.created_at),
|
||||
"created_by": self._get_created_by(comment),
|
||||
}
|
||||
for comment in i.issue_comments.all()
|
||||
]
|
||||
|
||||
def prepare_estimate(self, i):
|
||||
return i.estimate_point.value if i.estimate_point and i.estimate_point.value else ""
|
||||
|
||||
def prepare_link(self, i):
|
||||
return [link.url for link in i.issue_link.all()]
|
||||
|
||||
def prepare_assignees(self, i):
|
||||
return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()]
|
||||
|
||||
def prepare_subscribers_count(self, i):
|
||||
return i.issue_subscribers.count()
|
||||
|
||||
def prepare_attachment_count(self, i):
|
||||
return len((self.context.get("attachments_dict") or {}).get(i.id, []))
|
||||
|
||||
def prepare_attachment_links(self, i):
|
||||
return [
|
||||
f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/"
|
||||
for asset in (self.context.get("attachments_dict") or {}).get(i.id, [])
|
||||
]
|
||||
|
||||
def prepare_cycle_name(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
return last_cycle.cycle.name if last_cycle else ""
|
||||
|
||||
def prepare_cycle_start_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
if last_cycle and last_cycle.cycle.start_date:
|
||||
return self._format_date(last_cycle.cycle.start_date)
|
||||
return ""
|
||||
|
||||
|
||||
def prepare_cycle_end_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
if last_cycle and last_cycle.cycle.end_date:
|
||||
return self._format_date(last_cycle.cycle.end_date)
|
||||
return ""
|
||||
|
||||
def prepare_parent(self, i):
|
||||
if not i.parent:
|
||||
return ""
|
||||
return f"{i.parent.project.identifier}-{i.parent.sequence_id}"
|
||||
|
||||
def prepare_relations(self, i):
|
||||
# Should show reverse relation as well
|
||||
from plane.db.models.issue import IssueRelationChoices
|
||||
|
||||
relations = {
|
||||
r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}"
|
||||
for r in i.issue_relation.all()
|
||||
}
|
||||
reverse_relations = {}
|
||||
for relation in i.issue_related.all():
|
||||
reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = (
|
||||
f"{relation.issue.project.identifier}-{relation.issue.sequence_id}"
|
||||
)
|
||||
relations.update(reverse_relations)
|
||||
return relations
|
||||
|
||||
@classmethod
|
||||
def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]:
|
||||
"""Get context data for issue serialization."""
|
||||
return {
|
||||
"attachments_dict": get_issue_attachments_dict(queryset),
|
||||
"cycles_dict": get_issue_last_cycles_dict(queryset),
|
||||
}
|
||||
@@ -159,7 +159,6 @@ class IssueFilterSet(BaseFilterSet):
|
||||
"start_date": ["exact", "range"],
|
||||
"target_date": ["exact", "range"],
|
||||
"created_at": ["exact", "range"],
|
||||
"updated_at": ["exact", "range"],
|
||||
"is_draft": ["exact"],
|
||||
"priority": ["exact", "in"],
|
||||
}
|
||||
|
||||
+11
-9
@@ -20,17 +20,14 @@
|
||||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "3.2.5",
|
||||
"@hocuspocus/extension-logger": "3.2.5",
|
||||
"@hocuspocus/extension-redis": "3.2.5",
|
||||
"@hocuspocus/server": "3.2.5",
|
||||
"@hocuspocus/transformer": "3.2.5",
|
||||
"@hocuspocus/extension-database": "^3.0.0",
|
||||
"@hocuspocus/extension-logger": "^3.0.0",
|
||||
"@hocuspocus/extension-redis": "^3.0.0",
|
||||
"@hocuspocus/server": "^3.0.0",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@sentry/node": "catalog:",
|
||||
"@sentry/profiling-node": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/html": "catalog:",
|
||||
"axios": "catalog:",
|
||||
@@ -40,7 +37,10 @@
|
||||
"express": "^4.21.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "5.7.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"morgan": "1.10.1",
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"uuid": "catalog:",
|
||||
"ws": "^8.18.3",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
@@ -55,7 +55,9 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express-ws": "^3.0.5",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/pino-http": "^5.8.4",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -22,11 +22,11 @@ export class CollaborationController {
|
||||
|
||||
// Set up error handling for the connection
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Request, Response } from "express";
|
||||
// plane imports
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
// types
|
||||
import type { TConvertDocumentRequestBody } from "@/types";
|
||||
// utils
|
||||
import { convertHTMLDocumentToAllFormats } from "@/utils";
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class ConvertDocumentController {
|
||||
@Post("/")
|
||||
handleConvertDocument(req: Request, res: Response) {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (typeof description_html !== "string" || variant === undefined) {
|
||||
res.status(400).json({
|
||||
message: "Missing required fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error in /convert-document endpoint:", error);
|
||||
res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
// helpers
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
import { type TConvertDocumentRequestBody } from "@/types";
|
||||
|
||||
// Define the schema with more robust validation
|
||||
const convertDocumentSchema = z.object({
|
||||
description_html: z
|
||||
.string()
|
||||
.min(1, "HTML content cannot be empty")
|
||||
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
|
||||
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
|
||||
variant: z.enum(["rich", "document"]),
|
||||
});
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class DocumentController {
|
||||
@Post("/")
|
||||
async convertDocument(req: Request, res: Response) {
|
||||
try {
|
||||
// Validate request body
|
||||
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
|
||||
const { description_html, variant } = validatedData;
|
||||
|
||||
// Process document conversion
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationErrors = error.errors.map((err) => ({
|
||||
path: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
logger.error("DOCUMENT_CONTROLLER: Validation error", {
|
||||
validationErrors,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message: `Validation error`,
|
||||
context: {
|
||||
validationErrors,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error("DOCUMENT_CONTROLLER: Internal server error", error);
|
||||
return res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { ConvertDocumentController } from "./convert-document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
|
||||
export const CONTROLLERS = [CollaborationController, ConvertDocumentController, HealthController];
|
||||
|
||||
@@ -38,7 +38,7 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
|
||||
// return binary data
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
|
||||
logger.error("Error in fetching document", error);
|
||||
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
|
||||
}
|
||||
};
|
||||
@@ -57,7 +57,7 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
|
||||
logger.error("Error in updating document:", error);
|
||||
throw normalizeToError(error, `Failed to update document: ${pageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
export const setupSentry = () => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()],
|
||||
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5,
|
||||
environment: process.env.SENTRY_ENVIRONMENT || "development",
|
||||
release: process.env.APP_VERSION || "v1.0.0",
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -36,7 +36,7 @@ export const onAuthenticate = async ({
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
logger.error("Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
@@ -76,8 +76,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
||||
name: user.display_name,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
} catch (_error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
};
|
||||
|
||||
+18
-18
@@ -19,12 +19,12 @@ export class RedisManager {
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.redisClient && this.isConnected) {
|
||||
logger.info("REDIS_MANAGER: client already initialized and connected");
|
||||
logger.info("Redis client already initialized and connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectionPromise) {
|
||||
logger.info("REDIS_MANAGER: Redis connection already in progress, waiting...");
|
||||
logger.info("Redis connection already in progress, waiting...");
|
||||
await this.connectionPromise;
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export class RedisManager {
|
||||
const redisUrl = this.getRedisUrl();
|
||||
|
||||
if (!redisUrl) {
|
||||
logger.warn("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled");
|
||||
logger.warn("No Redis URL provided, Redis functionality will be disabled");
|
||||
this.isConnected = false;
|
||||
return;
|
||||
}
|
||||
@@ -70,27 +70,27 @@ export class RedisManager {
|
||||
|
||||
// Set up event listeners
|
||||
this.redisClient.on("connect", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client connected");
|
||||
logger.info("Redis client connected");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("ready", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client ready");
|
||||
logger.info("Redis client ready");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("error", (error) => {
|
||||
logger.error("REDIS_MANAGER: Redis client error:", error);
|
||||
logger.error("Redis client error:", error);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("close", () => {
|
||||
logger.warn("REDIS_MANAGER: Redis client connection closed");
|
||||
logger.warn("Redis client connection closed");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("reconnecting", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client reconnecting...");
|
||||
logger.info("Redis client reconnecting...");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
@@ -99,9 +99,9 @@ export class RedisManager {
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("REDIS_MANAGER: Redis connection test successful");
|
||||
logger.info("Redis connection test successful");
|
||||
} catch (error) {
|
||||
logger.error("REDIS_MANAGER: Failed to initialize Redis client:", error);
|
||||
logger.error("Failed to initialize Redis client:", error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -111,7 +111,7 @@ export class RedisManager {
|
||||
|
||||
public getClient(): Redis | null {
|
||||
if (!this.redisClient || !this.isConnected) {
|
||||
logger.warn("REDIS_MANAGER: Redis client not available or not connected");
|
||||
logger.warn("Redis client not available or not connected");
|
||||
return null;
|
||||
}
|
||||
return this.redisClient;
|
||||
@@ -125,9 +125,9 @@ export class RedisManager {
|
||||
if (this.redisClient) {
|
||||
try {
|
||||
await this.redisClient.quit();
|
||||
logger.info("REDIS_MANAGER: Redis client disconnected gracefully");
|
||||
logger.info("Redis client disconnected gracefully");
|
||||
} catch (error) {
|
||||
logger.error("REDIS_MANAGER: Error disconnecting Redis client:", error);
|
||||
logger.error("Error disconnecting Redis client:", error);
|
||||
// Force disconnect if quit fails
|
||||
this.redisClient.disconnect();
|
||||
} finally {
|
||||
@@ -150,7 +150,7 @@ export class RedisManager {
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`REDIS_MANAGER: Error setting Redis key ${key}:`, error);
|
||||
logger.error(`Error setting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ export class RedisManager {
|
||||
try {
|
||||
return await client.get(key);
|
||||
} catch (error) {
|
||||
logger.error(`REDIS_MANAGER: Error getting Redis key ${key}:`, error);
|
||||
logger.error(`Error getting Redis key ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export class RedisManager {
|
||||
await client.del(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`REDIS_MANAGER: Error deleting Redis key ${key}:`, error);
|
||||
logger.error(`Error deleting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export class RedisManager {
|
||||
const result = await client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`REDIS_MANAGER: Error checking Redis key ${key}:`, error);
|
||||
logger.error(`Error checking Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ export class RedisManager {
|
||||
const result = await client.expire(key, ttl);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error);
|
||||
logger.error(`Error setting expiry for Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,15 +35,15 @@ export class Server {
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
await redisManager.initialize();
|
||||
logger.info("SERVER: Redis setup completed");
|
||||
logger.info("Redis setup completed");
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("SERVER: HocusPocus setup completed");
|
||||
logger.info("HocusPocus setup completed");
|
||||
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
logger.error("SERVER: Failed to initialize live server dependencies:", error);
|
||||
logger.error("Failed to initialize live server dependencies:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,10 @@ export class Server {
|
||||
public listen() {
|
||||
this.httpServer = this.app
|
||||
.listen(this.app.get("port"), () => {
|
||||
logger.info(`SERVER: Express server has started at port ${this.app.get("port")}`);
|
||||
logger.info(`Plane Live server has started at port ${this.app.get("port")}`);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
logger.error("SERVER: Failed to start server:", err);
|
||||
logger.error("Failed to start server:", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
@@ -100,11 +100,11 @@ export class Server {
|
||||
public async destroy() {
|
||||
if (this.hocuspocusServer) {
|
||||
this.hocuspocusServer.closeConnections();
|
||||
logger.info("SERVER: HocusPocus connections closed gracefully.");
|
||||
logger.info("HocusPocus connections closed gracefully.");
|
||||
}
|
||||
|
||||
await redisManager.disconnect();
|
||||
logger.info("SERVER: Redis connection closed gracefully.");
|
||||
logger.info("Redis connection closed gracefully.");
|
||||
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -112,7 +112,7 @@ export class Server {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info("SERVER: Express server closed gracefully.");
|
||||
logger.info("Express server closed gracefully.");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
|
||||
export abstract class APIService {
|
||||
@@ -14,17 +13,6 @@ export abstract class APIService {
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
logger.error("AXIOS_ERROR:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setHeader(key: string, value: string) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { setupSentry } from "./instrument";
|
||||
setupSentry();
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import { logger } from "@plane/logger";
|
||||
import { Server } from "./server";
|
||||
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
rules: {
|
||||
"no-duplicate-imports": "off",
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "separate-type-imports",
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
// plane imports
|
||||
import { SitesProjectPublishService } from "@plane/services";
|
||||
import type { TProjectPublishSettings } from "@plane/types";
|
||||
import { TProjectPublishSettings } from "@plane/types";
|
||||
|
||||
const publishService = new SitesProjectPublishService();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Metadata } from "next";
|
||||
// helpers
|
||||
import { SPACE_BASE_PATH } from "@plane/constants";
|
||||
// styles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, FC } from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
// components
|
||||
import { TranslationProvider } from "@plane/i18n";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// plane editor
|
||||
import type { TMentionComponentProps } from "@plane/editor";
|
||||
import { TMentionComponentProps } from "@plane/editor";
|
||||
|
||||
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user