Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 733b725b5e | |||
| 3bee051ee9 | |||
| 6b902d458e | |||
| 51ca353577 | |||
| 881c744eb9 | |||
| ec41ae61b4 | |||
| 5773c2bde3 | |||
| e33bae2125 | |||
| 580c4b1930 | |||
| ddd4b51b4e | |||
| ede4aad55b | |||
| 1a715c98b2 | |||
| 8e6d885731 | |||
| 4507802aba | |||
| 438cc33046 | |||
| 442b0fd7e5 | |||
| 1119b9dc36 | |||
| 47a76f48b4 | |||
| a0f03d07f3 | |||
| 74b2ec03ff | |||
| 5908998127 | |||
| df6a80e7ae | |||
| 6ff258ceca | |||
| a8140a5f08 | |||
| 9234f21f26 | |||
| ab11e83535 | |||
| b4112358ac | |||
| 77239ebcd4 | |||
| 54f828cbfa | |||
| 9ad8b43408 | |||
| 38e8a5c807 | |||
| a9bd2e243a | |||
| ca0d50b229 | |||
| 7fca7fd86c | |||
| 0ac68f2731 | |||
| 5a9ae66680 | |||
| 134644fdf1 | |||
| d0f3987aeb | |||
| f06b1b8c4a | |||
| 6e56ea4c60 | |||
| 216a69f991 | |||
| 205395e079 | |||
| ff8bbed6f9 | |||
| d04619477b | |||
| 547c138084 | |||
| 5c907db0e2 | |||
| a85e592ada | |||
| b21d190ce0 | |||
| cba41e0755 | |||
| 02308eeb15 | |||
| 9ee41ece98 | |||
| 666ddf73b6 | |||
| 4499a5fa25 | |||
| 727dd4002e | |||
| 4b5a2bc4e5 | |||
| b1c340b199 | |||
| a612a17d28 | |||
| d55ee6d5b8 | |||
| aa1e192a50 | |||
| 6cd8af1092 | |||
| 66652a5d71 | |||
| 3bccda0c86 | |||
| fb3295f5f4 | |||
| fa3aa362a9 | |||
| b73ea37798 | |||
| d537e560e3 | |||
| 1b92a18ef8 | |||
| 31b6d52417 | |||
| a153de34d6 | |||
| 64a44f4fce | |||
| bb8a156bdd | |||
| f02a2b04a5 | |||
| b6ab853c57 | |||
| fe43300aa7 | |||
| 849d9891d2 | |||
| 2768f560ad | |||
| fe5999ceff | |||
| da0071256f | |||
| 3c6006d04a | |||
| 8c04aa6f51 | |||
| 9f14167ef5 | |||
| 11bfbe560a | |||
| fc52936024 | |||
| 5150c661ab | |||
| 63bc01f385 | |||
| 1953d6fe3a | |||
| 1b9033993d | |||
| 75ada1bfac | |||
| d0f9a4d245 | |||
| 05894c5b9c | |||
| 5926c9e8e9 | |||
| 5aeedd1e5a | |||
| 7725b200f7 | |||
| 2c69538617 |
@@ -0,0 +1,20 @@
|
||||
### Description
|
||||
<!-- Provide a detailed description of the changes in this PR -->
|
||||
|
||||
### Type of Change
|
||||
<!-- Put an 'x' in the boxes that apply -->
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Feature (non-breaking change which adds functionality)
|
||||
- [ ] Improvement (change that would cause existing functionality to not work as expected)
|
||||
- [ ] Code refactoring
|
||||
- [ ] Performance improvements
|
||||
- [ ] Documentation update
|
||||
|
||||
### Screenshots and Media (if applicable)
|
||||
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
|
||||
|
||||
### Test Scenarios
|
||||
<!-- Please describe the tests that you ran to verify your changes -->
|
||||
|
||||
### References
|
||||
<!-- Link related issues if there are any -->
|
||||
@@ -314,8 +314,8 @@ jobs:
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
attach_assets_to_build:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
|
||||
name: Attach Assets to Build
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center"><b>Plane</b></h3>
|
||||
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
|
||||
<h1 align="center"><b>Plane</b></h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
@@ -44,79 +42,85 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage
|
||||
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
|
||||
|
||||
## ⚡ Installation
|
||||
## 🚀 Installation
|
||||
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
|
||||
Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
|
||||
- **Plane Cloud**
|
||||
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
|
||||
|
||||
- **Self-host Plane**
|
||||
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
|
||||
|
||||
| Installation methods | Docs link |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Docker | [](https://docs.plane.so/self-hosting/methods/docker-compose) |
|
||||
| Kubernetes | [](https://docs.plane.so/kubernetes) |
|
||||
| Docker | [](https://developers.plane.so/self-hosting/methods/docker-compose) |
|
||||
| Kubernetes | [](https://developers.plane.so/self-hosting/methods/kubernetes) |
|
||||
|
||||
`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
|
||||
`Instance admins` can manage and customize settings using [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
|
||||
|
||||
## 🚀 Features
|
||||
## 🌟 Features
|
||||
|
||||
- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking.
|
||||
- **Issues**
|
||||
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
||||
|
||||
- **Cycles**:
|
||||
Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features.
|
||||
- **Cycles**
|
||||
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
||||
|
||||
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
|
||||
- **Modules**
|
||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||
|
||||
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
|
||||
- **Views**
|
||||
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
||||
|
||||
- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue.
|
||||
- **Pages**
|
||||
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
|
||||
|
||||
- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work.
|
||||
- **Analytics**
|
||||
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
||||
|
||||
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||
|
||||
## 🛠️ Quick start for contributors
|
||||
|
||||
> Development system must have docker engine installed and running.
|
||||
## 🛠️ Local development
|
||||
|
||||
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute -
|
||||
### Pre-requisites
|
||||
- Ensure Docker Engine is installed and running.
|
||||
|
||||
1. Clone the code locally using:
|
||||
### Development setup
|
||||
Setting up your local environment is simple and straightforward. Follow these steps to get started:
|
||||
|
||||
1. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/makeplane/plane.git
|
||||
```
|
||||
2. Switch to the code folder:
|
||||
2. Navigate to the project folder:
|
||||
```
|
||||
cd plane
|
||||
```
|
||||
3. Create your feature or fix branch you plan to work on using:
|
||||
3. Create a new branch for your feature or fix:
|
||||
```
|
||||
git checkout -b <feature-branch-name>
|
||||
```
|
||||
4. Open terminal and run:
|
||||
4. Run the setup script in the terminal:
|
||||
```
|
||||
./setup.sh
|
||||
```
|
||||
5. Open the code on VSCode or similar equivalent IDE.
|
||||
6. Review the `.env` files available in various folders.
|
||||
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
|
||||
7. Run the docker command to initiate services:
|
||||
5. Open the project in an IDE such as VS Code.
|
||||
|
||||
6. Review the `.env` files in the relevant folders. Refer to [Environment Setup](./ENV_SETUP.md) for details on the environment variables used.
|
||||
|
||||
7. Start the services using Docker:
|
||||
```
|
||||
docker compose -f docker-compose-local.yml up -d
|
||||
```
|
||||
|
||||
You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload).
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
Thats it!
|
||||
|
||||
## ❤️ Community
|
||||
|
||||
The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels.
|
||||
|
||||
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
|
||||
|
||||
### Repo Activity
|
||||
|
||||

|
||||
## Built with
|
||||
[](https://nextjs.org/)<br/>
|
||||
[](https://www.djangoproject.com/)<br/>
|
||||
[](https://nodejs.org/en)
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -165,7 +169,7 @@ Ask questions, report bugs, join discussions, voice ideas, make feature requests
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
<p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
|
||||
@@ -176,23 +180,42 @@ Ask questions, report bugs, join discussions, voice ideas, make feature requests
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## ⛓️ Security
|
||||
## 📝 Documentation
|
||||
Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage.
|
||||
|
||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
|
||||
## ❤️ Community
|
||||
|
||||
Email squawk@plane.so to disclose any security vulnerabilities.
|
||||
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
|
||||
|
||||
## ❤️ Contribute
|
||||
Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you!
|
||||
|
||||
There are many ways to contribute to Plane, including:
|
||||
## 🛡️ Security
|
||||
|
||||
- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components.
|
||||
- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features.
|
||||
- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)!
|
||||
- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support.
|
||||
If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info.
|
||||
|
||||
To disclose any security issues, please email us at security@plane.so.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
There are many ways you can contribute to Plane:
|
||||
|
||||
- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+).
|
||||
- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content.
|
||||
- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)!
|
||||
- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues).
|
||||
|
||||
Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us.
|
||||
|
||||
### Repo activity
|
||||
|
||||

|
||||
|
||||
### We couldn't have done this without you.
|
||||
|
||||
<a href="https://github.com/makeplane/plane/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
||||
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
|
||||
loading: "Saving Configuration...",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
|
||||
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
@@ -67,8 +67,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<AuthenticationMethodCard
|
||||
name="Github"
|
||||
description="Allow members to login or sign up to plane with their Github accounts."
|
||||
name="GitHub"
|
||||
description="Allow members to login or sign up to plane with their GitHub accounts."
|
||||
icon={
|
||||
<Image
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
|
||||
@@ -38,6 +38,8 @@ export const WorkspaceCreateForm = () => {
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
|
||||
// derived values
|
||||
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
@@ -124,7 +126,7 @@ export const WorkspaceCreateForm = () => {
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm text-custom-text-300">Set your workspace's URL</h4>
|
||||
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span>
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// components
|
||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||
// hooks
|
||||
|
||||
@@ -30,7 +30,7 @@ export const InstanceHeader: FC = observer(() => {
|
||||
case "google":
|
||||
return "Google";
|
||||
case "github":
|
||||
return "Github";
|
||||
return "GitHub";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
case "workspace":
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// helpers
|
||||
import { Tooltip } from "@plane/ui";
|
||||
@@ -20,7 +19,7 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
|
||||
|
||||
if (!workspace) return null;
|
||||
return (
|
||||
<Link
|
||||
<a
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
@@ -77,6 +76,6 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
|
||||
<div className="flex-shrink-0">
|
||||
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
@@ -14,9 +14,10 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@sentry/nextjs": "^8.32.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
@@ -26,7 +27,7 @@
|
||||
"lucide-react": "^0.356.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.12",
|
||||
"next": "^14.2.20",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.0"
|
||||
"version": "0.24.1"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Cycle, CycleIssue
|
||||
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
@@ -24,6 +24,18 @@ class CycleSerializer(BaseSerializer):
|
||||
and data.get("start_date", None) > data.get("end_date", None)
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
data["start_date"] = convert_to_utc(
|
||||
str(data.get("start_date").date()), project_id, is_start_date=True
|
||||
)
|
||||
data["end_date"] = convert_to_utc(
|
||||
str(data.get("end_date", None).date()), project_id
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
|
||||
class CycleWriteSerializer(BaseSerializer):
|
||||
@@ -15,6 +16,17 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
and data.get("start_date", None) > data.get("end_date", None)
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
data["start_date"] = convert_to_utc(
|
||||
str(data.get("start_date").date()), project_id, is_start_date=True
|
||||
)
|
||||
data["end_date"] = convert_to_utc(
|
||||
str(data.get("end_date", None).date()), project_id
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -68,9 +68,7 @@ urlpatterns = [
|
||||
# user workspace invitations
|
||||
path(
|
||||
"users/me/workspaces/invitations/",
|
||||
UserWorkspaceInvitationsViewSet.as_view(
|
||||
{"get": "list", "post": "create"}
|
||||
),
|
||||
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="user-workspace-invitations",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -126,7 +126,13 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
|
||||
allowed_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
import pytz
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -52,6 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.timezone_converter import (
|
||||
convert_utc_to_project_timezone,
|
||||
convert_to_utc,
|
||||
user_timezone_converter,
|
||||
)
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@@ -67,6 +74,19 @@ class CycleViewSet(BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
|
||||
project = Project.objects.get(id=self.kwargs.get("project_id"))
|
||||
|
||||
# Fetch project for the specific record or pass project_id dynamically
|
||||
project_timezone = project.timezone
|
||||
|
||||
# Convert the current time (timezone.now()) to the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
current_time_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
|
||||
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
|
||||
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -119,12 +139,15 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=current_time_in_utc)
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
start_date__gt=current_time_in_utc,
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
@@ -160,10 +183,22 @@ class CycleViewSet(BaseViewSet):
|
||||
# Update the order by
|
||||
queryset = queryset.order_by("-is_favorite", "-created_at")
|
||||
|
||||
project = Project.objects.get(id=self.kwargs.get("project_id"))
|
||||
|
||||
# Fetch project for the specific record or pass project_id dynamically
|
||||
project_timezone = project.timezone
|
||||
|
||||
# Convert the current time (timezone.now()) to the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
current_time_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
|
||||
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
|
||||
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
|
||||
)
|
||||
|
||||
data = queryset.values(
|
||||
@@ -191,6 +226,8 @@ class CycleViewSet(BaseViewSet):
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, project_timezone)
|
||||
|
||||
if data:
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
@@ -221,6 +258,8 @@ class CycleViewSet(BaseViewSet):
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
@@ -417,6 +456,8 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
queryset = queryset.first()
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
@@ -492,6 +533,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
|
||||
end_date = convert_to_utc(str(end_date), project_id)
|
||||
|
||||
# Check if any cycle intersects in the given interval
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(workspace__slug=slug)
|
||||
|
||||
@@ -15,8 +15,6 @@ from django.db.models import (
|
||||
UUIDField,
|
||||
Value,
|
||||
Subquery,
|
||||
Case,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
@@ -56,10 +54,11 @@ from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
@@ -430,6 +429,13 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
issue_id=str(serializer.data["id"]),
|
||||
user_id=request.user.id,
|
||||
is_creating=True,
|
||||
)
|
||||
return Response(issue, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -445,12 +451,10 @@ class IssueViewSet(BaseViewSet):
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Case(
|
||||
When(
|
||||
issue_cycle__cycle__deleted_at__isnull=True,
|
||||
then=F("issue_cycle__cycle_id"),
|
||||
),
|
||||
default=None,
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||
:1
|
||||
]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@@ -653,6 +657,12 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.app.serializers import ModuleDetailSerializer
|
||||
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
# Module imports
|
||||
|
||||
@@ -56,7 +56,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
|
||||
@@ -114,7 +114,7 @@ class PageViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
@@ -134,7 +134,7 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
@@ -234,7 +234,7 @@ class PageViewSet(BaseViewSet):
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
@@ -244,7 +244,7 @@ class PageViewSet(BaseViewSet):
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
@@ -255,7 +255,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def access(self, request, slug, project_id, pk):
|
||||
access = request.data.get("access", 0)
|
||||
page = Page.objects.filter(
|
||||
@@ -296,7 +296,7 @@ class PageViewSet(BaseViewSet):
|
||||
pages = PageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
@@ -323,7 +323,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
@@ -348,7 +348,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
|
||||
@@ -16,12 +16,7 @@ from plane.app.permissions import (
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
IssueUserProperty,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
|
||||
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.app.permissions.base import allow_permission, ROLE
|
||||
@@ -83,10 +78,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
workspace_member_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=member, is_active=True
|
||||
).role
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [
|
||||
5,
|
||||
15,
|
||||
]:
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role lower than the workspace role"
|
||||
@@ -94,10 +86,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [
|
||||
15,
|
||||
20,
|
||||
]:
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role higher than the workspace role"
|
||||
@@ -135,8 +124,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
sort_order = [
|
||||
project_member.get("sort_order")
|
||||
for project_member in project_members
|
||||
if str(project_member.get("member_id"))
|
||||
== str(member.get("member_id"))
|
||||
if str(project_member.get("member_id")) == str(member.get("member_id"))
|
||||
]
|
||||
# Create a new project member
|
||||
bulk_project_members.append(
|
||||
@@ -145,9 +133,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
role=member.get("role", 5),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
sort_order=(
|
||||
sort_order[0] - 10000 if len(sort_order) else 65535
|
||||
),
|
||||
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
|
||||
)
|
||||
)
|
||||
# Create a new issue property
|
||||
@@ -238,9 +224,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -280,9 +264,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
# User cannot deactivate higher role
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -303,10 +285,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
project_member.role == 20
|
||||
and not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
role=20,
|
||||
is_active=True,
|
||||
workspace__slug=slug, project_id=project_id, role=20, is_active=True
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
@@ -344,7 +323,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
str(member["project_id"]): member["role"]
|
||||
for member in project_members
|
||||
str(member["project_id"]): member["role"] for member in project_members
|
||||
}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -41,6 +41,7 @@ from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
model = Workspace
|
||||
serializer_class = WorkSpaceSerializer
|
||||
@@ -81,12 +82,12 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
|
||||
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "DISABLE_WORKSPACE_CREATION",
|
||||
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Cycle
|
||||
from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.app.serializers.cycle import CycleSerializer
|
||||
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Q,
|
||||
OuterRef,
|
||||
Subquery,
|
||||
IntegerField,
|
||||
)
|
||||
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
)
|
||||
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
@@ -26,12 +16,7 @@ from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
WorkspaceMember,
|
||||
DraftIssue,
|
||||
)
|
||||
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
from .. import BaseViewSet
|
||||
@@ -119,9 +104,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -148,9 +131,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
).update(is_active=False)
|
||||
|
||||
workspace_member.is_active = False
|
||||
@@ -164,9 +145,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
multiple=True,
|
||||
)
|
||||
@invalidate_cache(path="/api/users/me/settings/")
|
||||
@invalidate_cache(
|
||||
path="api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
@@ -213,9 +192,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
).update(is_active=False)
|
||||
|
||||
# # Deactivate the user
|
||||
@@ -279,9 +256,7 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id__in=project_ids, is_active=True
|
||||
).select_related("project", "member", "workspace")
|
||||
project_members = ProjectMemberRoleSerializer(
|
||||
project_members, many=True
|
||||
).data
|
||||
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
|
||||
|
||||
project_members_dict = dict()
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Lower the email
|
||||
email = str(email).lower().strip()
|
||||
|
||||
# Validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
email = str(email).lower().strip()
|
||||
# Validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -3,7 +3,8 @@ from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models.fields.related import OneToOneRel
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -11,31 +12,98 @@ from celery import shared_task
|
||||
|
||||
@shared_task
|
||||
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
"""
|
||||
Soft delete related objects for a given model instance
|
||||
"""
|
||||
# Get the model class using app registry
|
||||
model_class = apps.get_model(app_label, model_name)
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
related_fields = instance._meta.get_fields()
|
||||
for field in related_fields:
|
||||
if field.one_to_many or field.one_to_one:
|
||||
try:
|
||||
# Check if the field has CASCADE on delete
|
||||
if (
|
||||
not hasattr(field.remote_field, "on_delete")
|
||||
or field.remote_field.on_delete == models.CASCADE
|
||||
):
|
||||
if field.one_to_many:
|
||||
related_objects = getattr(instance, field.name).all()
|
||||
elif field.one_to_one:
|
||||
related_object = getattr(instance, field.name)
|
||||
related_objects = (
|
||||
[related_object] if related_object is not None else []
|
||||
)
|
||||
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
|
||||
try:
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
except model_class.DoesNotExist:
|
||||
return
|
||||
|
||||
# Get all related fields that are reverse relationships
|
||||
all_related = [
|
||||
f
|
||||
for f in instance._meta.get_fields()
|
||||
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
|
||||
]
|
||||
|
||||
# Handle each related field
|
||||
for relation in all_related:
|
||||
related_name = relation.get_accessor_name()
|
||||
|
||||
# Skip if the relation doesn't exist
|
||||
if not hasattr(instance, related_name):
|
||||
continue
|
||||
|
||||
# Get the on_delete behavior name
|
||||
on_delete_name = (
|
||||
relation.on_delete.__name__
|
||||
if hasattr(relation.on_delete, "__name__")
|
||||
else ""
|
||||
)
|
||||
|
||||
if on_delete_name == "DO_NOTHING":
|
||||
continue
|
||||
|
||||
elif on_delete_name == "SET_NULL":
|
||||
# Handle SET_NULL relationships
|
||||
if isinstance(relation, OneToOneRel):
|
||||
# For OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj and isinstance(related_obj, models.Model):
|
||||
setattr(related_obj, relation.remote_field.name, None)
|
||||
related_obj.save(update_fields=[relation.remote_field.name])
|
||||
else:
|
||||
# For other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset.update(**{relation.remote_field.name: None})
|
||||
|
||||
else:
|
||||
# Handle CASCADE and other delete behaviors
|
||||
try:
|
||||
if relation.one_to_one:
|
||||
# Handle OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error or handle as needed
|
||||
print(f"Error handling relation {related_name}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Finally, soft delete the instance itself if it hasn't been deleted yet
|
||||
if hasattr(instance, "deleted_at") and not instance.deleted_at:
|
||||
instance.deleted_at = timezone.now()
|
||||
instance.save()
|
||||
|
||||
|
||||
# @shared_task
|
||||
|
||||
@@ -162,8 +162,7 @@ def generate_table_row(issue):
|
||||
issue["priority"],
|
||||
(
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
(
|
||||
@@ -197,8 +196,7 @@ def generate_json_row(issue):
|
||||
"Priority": issue["priority"],
|
||||
"Created By": (
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Assignee": (
|
||||
@@ -208,17 +206,11 @@ def generate_json_row(issue):
|
||||
),
|
||||
"Labels": issue["labels__name"] if issue["labels__name"] else "",
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": dateConverter(
|
||||
issue["issue_cycle__cycle__start_date"]
|
||||
),
|
||||
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
"Module Name": issue["issue_module__module__name"],
|
||||
"Module Start Date": dateConverter(
|
||||
issue["issue_module__module__start_date"]
|
||||
),
|
||||
"Module Target Date": dateConverter(
|
||||
issue["issue_module__module__target_date"]
|
||||
),
|
||||
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Python imports
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def get_owner_id(issue: Issue) -> Optional[int]:
|
||||
"""Get the owner ID of the issue"""
|
||||
|
||||
if issue.updated_by_id:
|
||||
return issue.updated_by_id
|
||||
|
||||
if issue.created_by_id:
|
||||
return issue.created_by_id
|
||||
|
||||
# Find project admin as fallback
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project_id=issue.project_id,
|
||||
role=20, # Admin role
|
||||
).first()
|
||||
|
||||
return project_member.member_id if project_member else None
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
|
||||
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
base_query = Issue.objects
|
||||
total_issues_count = base_query.count()
|
||||
|
||||
if total_issues_count == 0:
|
||||
return
|
||||
|
||||
# Calculate batch range
|
||||
end_offset = min(offset + batch_size, total_issues_count)
|
||||
|
||||
# Fetch issues with related data
|
||||
issues_batch = (
|
||||
base_query.order_by("created_at")
|
||||
.select_related("workspace", "project")
|
||||
.only(
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description",
|
||||
)[offset:end_offset]
|
||||
)
|
||||
|
||||
if not issues_batch:
|
||||
return
|
||||
|
||||
version_objects = []
|
||||
for issue in issues_batch:
|
||||
# Validate required fields
|
||||
if not issue.workspace_id or not issue.project_id:
|
||||
logging.warning(
|
||||
f"Skipping {issue.id} - missing workspace_id or project_id"
|
||||
)
|
||||
continue
|
||||
|
||||
# Determine owned_by_id
|
||||
owned_by_id = get_owner_id(issue)
|
||||
if owned_by_id is None:
|
||||
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
|
||||
continue
|
||||
|
||||
# Create version object
|
||||
version_objects.append(
|
||||
IssueDescriptionVersion(
|
||||
workspace_id=issue.workspace_id,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
owned_by_id=owned_by_id,
|
||||
last_saved_at=timezone.now(),
|
||||
issue_id=issue.id,
|
||||
description_binary=issue.description_binary,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
description_json=issue.description,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create version objects
|
||||
if version_objects:
|
||||
IssueDescriptionVersion.objects.bulk_create(version_objects)
|
||||
|
||||
# Schedule next batch if needed
|
||||
if end_offset < total_issues_count:
|
||||
sync_issue_description_version.apply_async(
|
||||
kwargs={
|
||||
"batch_size": batch_size,
|
||||
"offset": end_offset,
|
||||
"countdown": countdown,
|
||||
},
|
||||
countdown=countdown,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_issue_description_version(batch_size=5000, countdown=300):
|
||||
sync_issue_description_version.delay(
|
||||
batch_size=int(batch_size), countdown=countdown
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from plane.db.models import Issue, IssueDescriptionVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def should_update_existing_version(
|
||||
version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
|
||||
) -> bool:
|
||||
if not version:
|
||||
return
|
||||
|
||||
time_difference = (timezone.now() - version.last_saved_at).total_seconds()
|
||||
return (
|
||||
str(version.owned_by_id) == str(user_id)
|
||||
and time_difference <= max_time_difference
|
||||
)
|
||||
|
||||
|
||||
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
|
||||
version.description_json = issue.description
|
||||
version.description_html = issue.description_html
|
||||
version.description_binary = issue.description_binary
|
||||
version.description_stripped = issue.description_stripped
|
||||
version.last_saved_at = timezone.now()
|
||||
|
||||
version.save(
|
||||
update_fields=[
|
||||
"description_json",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"last_saved_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_description_version_task(
|
||||
updated_issue, issue_id, user_id, is_creating=False
|
||||
) -> Optional[bool]:
|
||||
try:
|
||||
# Parse updated issue data
|
||||
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
|
||||
|
||||
# Get current issue
|
||||
issue = Issue.objects.get(id=issue_id)
|
||||
|
||||
# Check if description has changed
|
||||
if (
|
||||
current_issue.get("description_html") == issue.description_html
|
||||
and not is_creating
|
||||
):
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Get latest version
|
||||
latest_version = (
|
||||
IssueDescriptionVersion.objects.filter(issue_id=issue_id)
|
||||
.order_by("-last_saved_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Determine whether to update existing or create new version
|
||||
if should_update_existing_version(version=latest_version, user_id=user_id):
|
||||
update_existing_version(latest_version, issue)
|
||||
else:
|
||||
IssueDescriptionVersion.log_issue_description_version(issue, user_id)
|
||||
|
||||
return
|
||||
|
||||
except Issue.DoesNotExist:
|
||||
# Issue no longer exists, skip processing
|
||||
return
|
||||
except json.JSONDecodeError as e:
|
||||
log_exception(f"Invalid JSON for updated_issue: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(f"Error processing issue description version: {e}")
|
||||
return
|
||||
@@ -0,0 +1,254 @@
|
||||
# Python imports
|
||||
import json
|
||||
from typing import Optional, List, Dict
|
||||
from uuid import UUID
|
||||
from itertools import groupby
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueVersion,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_task(updated_issue, issue_id, user_id):
|
||||
try:
|
||||
current_issue = json.loads(updated_issue) if updated_issue else {}
|
||||
issue = Issue.objects.get(id=issue_id)
|
||||
|
||||
updated_current_issue = {}
|
||||
for key, value in current_issue.items():
|
||||
if getattr(issue, key) != value:
|
||||
updated_current_issue[key] = value
|
||||
|
||||
if updated_current_issue:
|
||||
issue_version = (
|
||||
IssueVersion.objects.filter(issue_id=issue_id)
|
||||
.order_by("-last_saved_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if (
|
||||
issue_version
|
||||
and str(issue_version.owned_by) == str(user_id)
|
||||
and (timezone.now() - issue_version.last_saved_at).total_seconds()
|
||||
<= 600
|
||||
):
|
||||
for key, value in updated_current_issue.items():
|
||||
setattr(issue_version, key, value)
|
||||
issue_version.last_saved_at = timezone.now()
|
||||
issue_version.save(
|
||||
update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
|
||||
)
|
||||
else:
|
||||
IssueVersion.log_issue_version(issue, user_id)
|
||||
|
||||
return
|
||||
except Issue.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def get_owner_id(issue: Issue) -> Optional[int]:
|
||||
"""Get the owner ID of the issue"""
|
||||
|
||||
if issue.updated_by_id:
|
||||
return issue.updated_by_id
|
||||
|
||||
if issue.created_by_id:
|
||||
return issue.created_by_id
|
||||
|
||||
# Find project admin as fallback
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project_id=issue.project_id,
|
||||
role=20, # Admin role
|
||||
).first()
|
||||
|
||||
return project_member.member_id if project_member else None
|
||||
|
||||
|
||||
def get_related_data(issue_ids: List[UUID]) -> Dict:
|
||||
"""Get related data for the given issue IDs"""
|
||||
|
||||
cycle_issues = {
|
||||
ci.issue_id: ci.cycle_id
|
||||
for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
|
||||
}
|
||||
|
||||
# Get assignees with proper grouping
|
||||
assignee_records = list(
|
||||
IssueAssignee.objects.filter(issue_id__in=issue_ids)
|
||||
.values_list("issue_id", "assignee_id")
|
||||
.order_by("issue_id")
|
||||
)
|
||||
assignees = {}
|
||||
for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
|
||||
assignees[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get labels with proper grouping
|
||||
label_records = list(
|
||||
IssueLabel.objects.filter(issue_id__in=issue_ids)
|
||||
.values_list("issue_id", "label_id")
|
||||
.order_by("issue_id")
|
||||
)
|
||||
labels = {}
|
||||
for issue_id, group in groupby(label_records, key=lambda x: x[0]):
|
||||
labels[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get modules with proper grouping
|
||||
module_records = list(
|
||||
ModuleIssue.objects.filter(issue_id__in=issue_ids)
|
||||
.values_list("issue_id", "module_id")
|
||||
.order_by("issue_id")
|
||||
)
|
||||
modules = {}
|
||||
for issue_id, group in groupby(module_records, key=lambda x: x[0]):
|
||||
modules[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get latest activities
|
||||
latest_activities = {}
|
||||
activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by(
|
||||
"issue_id", "-created_at"
|
||||
)
|
||||
for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
|
||||
first_activity = next(activities_group, None)
|
||||
if first_activity:
|
||||
latest_activities[issue_id] = first_activity.id
|
||||
|
||||
return {
|
||||
"cycle_issues": cycle_issues,
|
||||
"assignees": assignees,
|
||||
"labels": labels,
|
||||
"modules": modules,
|
||||
"activities": latest_activities,
|
||||
}
|
||||
|
||||
|
||||
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
|
||||
"""Create IssueVersion object from the given issue and related data"""
|
||||
|
||||
try:
|
||||
if not issue.workspace_id or not issue.project_id:
|
||||
logging.warning(
|
||||
f"Skipping issue {issue.id} - missing workspace_id or project_id"
|
||||
)
|
||||
return None
|
||||
|
||||
owned_by_id = get_owner_id(issue)
|
||||
if owned_by_id is None:
|
||||
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
|
||||
return None
|
||||
|
||||
return IssueVersion(
|
||||
workspace_id=issue.workspace_id,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
owned_by_id=owned_by_id,
|
||||
last_saved_at=timezone.now(),
|
||||
activity_id=related_data["activities"].get(issue.id),
|
||||
properties=getattr(issue, "properties", {}),
|
||||
meta=getattr(issue, "meta", {}),
|
||||
issue_id=issue.id,
|
||||
parent=issue.parent_id,
|
||||
state=issue.state_id,
|
||||
estimate_point=issue.estimate_point_id,
|
||||
name=issue.name,
|
||||
priority=issue.priority,
|
||||
start_date=issue.start_date,
|
||||
target_date=issue.target_date,
|
||||
assignees=related_data["assignees"].get(issue.id, []),
|
||||
sequence_id=issue.sequence_id,
|
||||
labels=related_data["labels"].get(issue.id, []),
|
||||
sort_order=issue.sort_order,
|
||||
completed_at=issue.completed_at,
|
||||
archived_at=issue.archived_at,
|
||||
is_draft=issue.is_draft,
|
||||
external_source=issue.external_source,
|
||||
external_id=issue.external_id,
|
||||
type=issue.type_id,
|
||||
cycle=related_data["cycle_issues"].get(issue.id),
|
||||
modules=related_data["modules"].get(issue.id, []),
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_issue_version(batch_size=5000, offset=0, countdown=300):
|
||||
"""Task to create IssueVersion records for existing Issues in batches"""
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
base_query = Issue.objects
|
||||
total_issues_count = base_query.count()
|
||||
|
||||
if total_issues_count == 0:
|
||||
return
|
||||
|
||||
end_offset = min(offset + batch_size, total_issues_count)
|
||||
|
||||
# Get issues batch with optimized queries
|
||||
issues_batch = list(
|
||||
base_query.order_by("created_at")
|
||||
.select_related("workspace", "project")
|
||||
.all()[offset:end_offset]
|
||||
)
|
||||
|
||||
if not issues_batch:
|
||||
return
|
||||
|
||||
# Get all related data in bulk
|
||||
issue_ids = [issue.id for issue in issues_batch]
|
||||
related_data = get_related_data(issue_ids)
|
||||
|
||||
issue_versions = []
|
||||
for issue in issues_batch:
|
||||
version = create_issue_version(issue, related_data)
|
||||
if version:
|
||||
issue_versions.append(version)
|
||||
|
||||
# Bulk create versions
|
||||
if issue_versions:
|
||||
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
|
||||
|
||||
# Schedule the next batch if there are more workspaces to process
|
||||
if end_offset < total_issues_count:
|
||||
sync_issue_version.apply_async(
|
||||
kwargs={
|
||||
"batch_size": batch_size,
|
||||
"offset": end_offset,
|
||||
"countdown": countdown,
|
||||
},
|
||||
countdown=countdown,
|
||||
)
|
||||
|
||||
logging.info(f"Processed Issues: {end_offset}")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_issue_version(batch_size=5000, countdown=300):
|
||||
sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
|
||||
@@ -257,7 +257,9 @@ def notifications(
|
||||
)
|
||||
|
||||
new_mentions = [
|
||||
str(mention) for mention in new_mentions if mention in set(project_members)
|
||||
str(mention)
|
||||
for mention in new_mentions
|
||||
if mention in set(project_members)
|
||||
]
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance
|
||||
|
||||
@@ -13,28 +13,14 @@ from plane.db.models import (
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
help = "Add a member to a project. If present in the workspace"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
|
||||
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
|
||||
parser.add_argument(
|
||||
"--project_id",
|
||||
type=str,
|
||||
nargs="?",
|
||||
help="Project ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user_email",
|
||||
type=str,
|
||||
nargs="?",
|
||||
help="User Email",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
type=int,
|
||||
nargs="?",
|
||||
help="Role of the user in the project",
|
||||
"--role", type=int, nargs="?", help="Role of the user in the project"
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any):
|
||||
@@ -67,9 +53,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Get the smallest sort order
|
||||
smallest_sort_order = (
|
||||
ProjectMember.objects.filter(
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
ProjectMember.objects.filter(workspace_id=project.workspace_id)
|
||||
.order_by("sort_order")
|
||||
.first()
|
||||
)
|
||||
@@ -79,22 +63,15 @@ class Command(BaseCommand):
|
||||
else:
|
||||
sort_order = 65535
|
||||
|
||||
if ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
).exists():
|
||||
if ProjectMember.objects.filter(project=project, member=user).exists():
|
||||
# Update the project member
|
||||
ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
).update(is_active=True, sort_order=sort_order, role=role)
|
||||
ProjectMember.objects.filter(project=project, member=user).update(
|
||||
is_active=True, sort_order=sort_order, role=role
|
||||
)
|
||||
else:
|
||||
# Create the project member
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
member=user,
|
||||
role=role,
|
||||
sort_order=sort_order,
|
||||
project=project, member=user, role=role, sort_order=sort_order
|
||||
)
|
||||
|
||||
# Issue Property
|
||||
@@ -102,9 +79,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Success message
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"User {user_email} added to project {project_id}"
|
||||
)
|
||||
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
|
||||
)
|
||||
return
|
||||
except CommandError as e:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.issue_description_version_sync import (
|
||||
schedule_issue_description_version,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates IssueDescriptionVersion records for existing Issues in batches"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
batch_size = input("Enter the batch size: ")
|
||||
batch_countdown = input("Enter the batch countdown: ")
|
||||
|
||||
schedule_issue_description_version.delay(
|
||||
batch_size=batch_size, countdown=int(batch_countdown)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully created issue description version task")
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.issue_version_sync import schedule_issue_version
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates IssueVersion records for existing Issues in batches"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
batch_size = input("Enter the batch size: ")
|
||||
batch_countdown = input("Enter the batch countdown: ")
|
||||
|
||||
schedule_issue_version.delay(
|
||||
batch_size=batch_size, countdown=int(batch_countdown)
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
|
||||
@@ -0,0 +1,117 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-13 10:09
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import plane.db.models.user
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0086_issueversion_alter_teampage_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='issueversion',
|
||||
name='description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='issueversion',
|
||||
name='description_binary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='issueversion',
|
||||
name='description_html',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='issueversion',
|
||||
name='description_stripped',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueversion',
|
||||
name='activity',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_mobile_onboarded',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='mobile_onboarding_step',
|
||||
field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='mobile_timezone_auto_set',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='language',
|
||||
field=models.CharField(default='en', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issueversion',
|
||||
name='owned_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Sticky',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.TextField()),
|
||||
('description', models.JSONField(blank=True, default=dict)),
|
||||
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||
('description_stripped', models.TextField(blank=True, null=True)),
|
||||
('description_binary', models.BinaryField(null=True)),
|
||||
('logo_props', models.JSONField(default=dict)),
|
||||
('color', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('background_color', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sticky',
|
||||
'verbose_name_plural': 'Stickies',
|
||||
'db_table': 'stickies',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueDescriptionVersion',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('description_binary', models.BinaryField(null=True)),
|
||||
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||
('description_stripped', models.TextField(blank=True, null=True)),
|
||||
('description_json', models.JSONField(blank=True, default=dict)),
|
||||
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
|
||||
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Issue Description Version',
|
||||
'verbose_name_plural': 'Issue Description Versions',
|
||||
'db_table': 'issue_description_versions',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -41,6 +41,8 @@ from .issue import (
|
||||
IssueSequence,
|
||||
IssueSubscriber,
|
||||
IssueVote,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
|
||||
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
|
||||
@@ -53,7 +55,6 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from .deploy_board import DeployBoard
|
||||
from .session import Session
|
||||
from .social_connection import SocialLoginConnection
|
||||
from .state import State
|
||||
@@ -69,24 +70,6 @@ from .workspace import (
|
||||
WorkspaceUserProperties,
|
||||
)
|
||||
|
||||
from .importer import Importer
|
||||
|
||||
from .page import Page, PageLog, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .intake import Intake, IntakeIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
from .webhook import Webhook, WebhookLog
|
||||
|
||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
from .issue_type import IssueType
|
||||
@@ -96,3 +79,5 @@ from .recent_visit import UserRecentVisit
|
||||
from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -44,45 +44,25 @@ class FileAsset(BaseModel):
|
||||
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
draft_issue = models.ForeignKey(
|
||||
"db.DraftIssue",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
entity_identifier = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
entity_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@@ -15,6 +15,7 @@ from django import apps
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .base import BaseModel
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
@@ -660,11 +661,6 @@ class IssueVote(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
)
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -672,14 +668,11 @@ class IssueVersion(ProjectBaseModel):
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
)
|
||||
|
||||
parent = models.UUIDField(blank=True, null=True)
|
||||
state = models.UUIDField(blank=True, null=True)
|
||||
estimate_point = models.UUIDField(blank=True, null=True)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
@@ -688,9 +681,9 @@ class IssueVersion(ProjectBaseModel):
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
sequence_id = models.IntegerField(
|
||||
default=1, verbose_name="Issue Sequence ID"
|
||||
)
|
||||
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||
labels = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
@@ -698,29 +691,26 @@ class IssueVersion(ProjectBaseModel):
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
type = models.UUIDField(blank=True, null=True)
|
||||
cycle = models.UUIDField(null=True, blank=True)
|
||||
modules = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
properties = models.JSONField(default=dict) # issue properties
|
||||
meta = models.JSONField(default=dict) # issue meta
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.UUIDField()
|
||||
assignees = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="versions"
|
||||
)
|
||||
labels = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
cycle = models.UUIDField(
|
||||
activity = models.ForeignKey(
|
||||
"db.IssueActivity",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="versions",
|
||||
)
|
||||
modules = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_versions",
|
||||
)
|
||||
properties = models.JSONField(default=dict)
|
||||
meta = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Version"
|
||||
@@ -740,43 +730,93 @@ class IssueVersion(ProjectBaseModel):
|
||||
|
||||
Module = apps.get_model("db.Module")
|
||||
CycleIssue = apps.get_model("db.CycleIssue")
|
||||
IssueAssignee = apps.get_model("db.IssueAssignee")
|
||||
IssueLabel = apps.get_model("db.IssueLabel")
|
||||
|
||||
cycle_issue = CycleIssue.objects.filter(
|
||||
issue=issue,
|
||||
).first()
|
||||
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
|
||||
|
||||
cls.objects.create(
|
||||
issue=issue,
|
||||
parent=issue.parent,
|
||||
state=issue.state,
|
||||
point=issue.point,
|
||||
estimate_point=issue.estimate_point,
|
||||
parent=issue.parent_id,
|
||||
state=issue.state_id,
|
||||
estimate_point=issue.estimate_point_id,
|
||||
name=issue.name,
|
||||
description=issue.description,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
description_binary=issue.description_binary,
|
||||
priority=issue.priority,
|
||||
start_date=issue.start_date,
|
||||
target_date=issue.target_date,
|
||||
assignees=list(
|
||||
IssueAssignee.objects.filter(issue=issue).values_list(
|
||||
"assignee_id", flat=True
|
||||
)
|
||||
),
|
||||
sequence_id=issue.sequence_id,
|
||||
labels=list(
|
||||
IssueLabel.objects.filter(issue=issue).values_list(
|
||||
"label_id", flat=True
|
||||
)
|
||||
),
|
||||
sort_order=issue.sort_order,
|
||||
completed_at=issue.completed_at,
|
||||
archived_at=issue.archived_at,
|
||||
is_draft=issue.is_draft,
|
||||
external_source=issue.external_source,
|
||||
external_id=issue.external_id,
|
||||
type=issue.type,
|
||||
last_saved_at=issue.last_saved_at,
|
||||
assignees=issue.assignees,
|
||||
labels=issue.labels,
|
||||
cycle=cycle_issue.cycle if cycle_issue else None,
|
||||
modules=Module.objects.filter(issue=issue).values_list(
|
||||
"id", flat=True
|
||||
type=issue.type_id,
|
||||
cycle=cycle_issue.cycle_id if cycle_issue else None,
|
||||
modules=list(
|
||||
Module.objects.filter(issue=issue).values_list("id", flat=True)
|
||||
),
|
||||
properties={},
|
||||
meta={},
|
||||
last_saved_at=timezone.now(),
|
||||
owned_by=user,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
class IssueDescriptionVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="description_versions"
|
||||
)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_description_versions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Description Version"
|
||||
verbose_name_plural = "Issue Description Versions"
|
||||
db_table = "issue_description_versions"
|
||||
|
||||
@classmethod
|
||||
def log_issue_description_version(cls, issue, user):
|
||||
try:
|
||||
"""
|
||||
Log the issue description version
|
||||
"""
|
||||
cls.objects.create(
|
||||
workspace_id=issue.workspace_id,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
owned_by_id=user,
|
||||
last_saved_at=timezone.now(),
|
||||
issue_id=issue.id,
|
||||
description_binary=issue.description_binary,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
description_json=issue.description,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField()
|
||||
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
|
||||
logo_props = models.JSONField(default=dict)
|
||||
color = models.CharField(max_length=255, blank=True, null=True)
|
||||
background_color = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="stickies"
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Sticky"
|
||||
verbose_name_plural = "Stickies"
|
||||
db_table = "stickies"
|
||||
ordering = ("-created_at",)
|
||||
@@ -26,6 +26,14 @@ def get_default_onboarding():
|
||||
}
|
||||
|
||||
|
||||
def get_mobile_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
"workspace_create": False,
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
@@ -178,6 +186,12 @@ class Profile(TimeAuditModel):
|
||||
billing_address = models.JSONField(null=True)
|
||||
has_billing_address = models.BooleanField(default=False)
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
# mobile
|
||||
is_mobile_onboarded = models.BooleanField(default=False)
|
||||
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
|
||||
mobile_timezone_auto_set = models.BooleanField(default=False)
|
||||
# language
|
||||
language = models.CharField(max_length=255, default="en")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Profile"
|
||||
|
||||
@@ -29,9 +29,7 @@ def validate_domain(value):
|
||||
|
||||
class Webhook(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_webhooks",
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
|
||||
)
|
||||
url = models.URLField(
|
||||
validators=[validate_schema, validate_domain], max_length=1024
|
||||
|
||||
@@ -102,12 +102,7 @@ def get_default_display_properties():
|
||||
|
||||
|
||||
def get_issue_props():
|
||||
return {
|
||||
"subscribed": True,
|
||||
"assigned": True,
|
||||
"created": True,
|
||||
"all_issues": True,
|
||||
}
|
||||
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
|
||||
|
||||
|
||||
def slug_validator(value):
|
||||
@@ -136,9 +131,7 @@ class Workspace(BaseModel):
|
||||
max_length=48, db_index=True, unique=True, validators=[slug_validator]
|
||||
)
|
||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||
timezone = models.CharField(
|
||||
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
|
||||
)
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
@@ -167,10 +160,7 @@ class WorkspaceBaseModel(BaseModel):
|
||||
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
models.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
null=True,
|
||||
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -184,9 +174,7 @@ class WorkspaceBaseModel(BaseModel):
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member",
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
|
||||
)
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -221,9 +209,7 @@ class WorkspaceMember(BaseModel):
|
||||
|
||||
class WorkspaceMemberInvite(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member_invite",
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
|
||||
)
|
||||
email = models.CharField(max_length=255)
|
||||
accepted = models.BooleanField(default=False)
|
||||
@@ -283,9 +269,7 @@ class WorkspaceTheme(BaseModel):
|
||||
)
|
||||
name = models.CharField(max_length=300)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="themes",
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
|
||||
)
|
||||
colors = models.JSONField(default=dict)
|
||||
|
||||
@@ -320,9 +304,7 @@ class WorkspaceUserProperties(BaseModel):
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(
|
||||
default=get_default_display_properties
|
||||
)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
|
||||
|
||||
from .configuration import InstanceConfigurationSerializer
|
||||
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
||||
from .workspace import WorkspaceSerializer
|
||||
from .workspace import WorkspaceSerializer
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class UserLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "first_name", "last_name",]
|
||||
fields = ["id", "email", "first_name", "last_name"]
|
||||
|
||||
@@ -13,6 +13,8 @@ from .admin import (
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
)
|
||||
|
||||
from .changelog import ChangeLogEndpoint
|
||||
|
||||
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
|
||||
from .workspace import (
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# plane imports
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ChangeLogEndpoint(BaseAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def fetch_change_logs(self):
|
||||
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get(self, request):
|
||||
# Fetch the changelog
|
||||
if settings.INSTANCE_CHANGELOG_URL:
|
||||
data = self.fetch_change_logs()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "could not fetch changelog please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False, is_active=True
|
||||
).select_related("owner")
|
||||
)
|
||||
.select_related("owner")
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
workspaces = Workspace.objects.annotate(
|
||||
total_projects=project_count,
|
||||
total_members=member_count,
|
||||
total_projects=project_count, total_members=member_count
|
||||
)
|
||||
|
||||
# Add search functionality
|
||||
@@ -66,16 +66,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=workspaces,
|
||||
on_results=lambda results: WorkspaceSerializer(
|
||||
results, many=True,
|
||||
).data,
|
||||
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
|
||||
max_per_page=10,
|
||||
default_per_page=10,
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
serializer = WorkspaceSerializer (data=request.data)
|
||||
serializer = WorkspaceSerializer(data=request.data)
|
||||
|
||||
slug = request.data.get("slug", False)
|
||||
name = request.data.get("name", False)
|
||||
|
||||
@@ -11,14 +11,12 @@ from plane.license.api.views import (
|
||||
InstanceAdminUserMeEndpoint,
|
||||
InstanceAdminSignOutEndpoint,
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
ChangeLogEndpoint,
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", InstanceEndpoint.as_view(), name="instance"),
|
||||
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
|
||||
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
|
||||
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
|
||||
path(
|
||||
@@ -62,9 +60,5 @@ urlpatterns = [
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||
name="instance-workspace-availability",
|
||||
),
|
||||
path(
|
||||
"workspaces/",
|
||||
InstanceWorkSpaceEndpoint.as_view(),
|
||||
name="instance-workspace",
|
||||
),
|
||||
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
|
||||
]
|
||||
|
||||
@@ -262,6 +262,9 @@ CELERY_IMPORTS = (
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
# issue version tasks
|
||||
"plane.bgtasks.issue_version_sync",
|
||||
"plane.bgtasks.issue_description_version_sync",
|
||||
)
|
||||
|
||||
# Sentry Settings
|
||||
|
||||
@@ -10,9 +10,15 @@ from plane.space.views import (
|
||||
ProjectStatesEndpoint,
|
||||
ProjectLabelsEndpoint,
|
||||
ProjectMembersEndpoint,
|
||||
ProjectMetaDataEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"anchor/<str:anchor>/meta/",
|
||||
ProjectMetaDataEndpoint.as_view(),
|
||||
name="project-meta",
|
||||
),
|
||||
path(
|
||||
"anchor/<str:anchor>/settings/",
|
||||
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
|
||||
|
||||
@@ -91,6 +91,7 @@ def issue_on_results(issues, group_by, sub_group_by):
|
||||
Case(
|
||||
When(
|
||||
votes__isnull=False,
|
||||
votes__deleted_at__isnull=True,
|
||||
then=JSONObject(
|
||||
vote=F("votes__vote"),
|
||||
actor_details=JSONObject(
|
||||
@@ -117,13 +118,14 @@ def issue_on_results(issues, group_by, sub_group_by):
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(votes__isnull=False),
|
||||
filter=Q(votes__isnull=False,votes__deleted_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
reaction_items=ArrayAgg(
|
||||
Case(
|
||||
When(
|
||||
issue_reactions__isnull=False,
|
||||
issue_reactions__deleted_at__isnull=True,
|
||||
then=JSONObject(
|
||||
reaction=F("issue_reactions__reaction"),
|
||||
actor_details=JSONObject(
|
||||
@@ -150,7 +152,7 @@ def issue_on_results(issues, group_by, sub_group_by):
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(issue_reactions__isnull=False),
|
||||
filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
).values(*required_fields, "vote_items", "reaction_items")
|
||||
|
||||
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
|
||||
from .label import ProjectLabelsEndpoint
|
||||
|
||||
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
||||
|
||||
from .meta import ProjectMetaDataEndpoint
|
||||
|
||||
@@ -86,7 +86,13 @@ class EntityAssetEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Check if the file type is allowed
|
||||
allowed_types = ["image/jpeg", "image/png", "image/webp"]
|
||||
allowed_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
]
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -701,6 +701,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
Case(
|
||||
When(
|
||||
votes__isnull=False,
|
||||
votes__deleted_at__isnull=True,
|
||||
then=JSONObject(
|
||||
vote=F("votes__vote"),
|
||||
actor_details=JSONObject(
|
||||
@@ -732,7 +733,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Case(
|
||||
When(votes__isnull=False, then=True),
|
||||
When(
|
||||
votes__isnull=False,
|
||||
votes__deleted_at__isnull=True,
|
||||
then=True,
|
||||
),
|
||||
default=False,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
@@ -742,6 +747,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
Case(
|
||||
When(
|
||||
issue_reactions__isnull=False,
|
||||
issue_reactions__deleted_at__isnull=True,
|
||||
then=JSONObject(
|
||||
reaction=F("issue_reactions__reaction"),
|
||||
actor_details=JSONObject(
|
||||
@@ -775,7 +781,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Case(
|
||||
When(issue_reactions__isnull=False, then=True),
|
||||
When(
|
||||
issue_reactions__isnull=False,
|
||||
issue_reactions__deleted_at__isnull=True,
|
||||
then=True,
|
||||
),
|
||||
default=False,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# third party
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.db.models import DeployBoard, Project
|
||||
|
||||
from .base import BaseAPIView
|
||||
from plane.space.serializer.project import ProjectLiteSerializer
|
||||
|
||||
|
||||
class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
try:
|
||||
project_id = deploy_board.entity_identifier
|
||||
project = Project.objects.get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = ProjectLiteSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytz
|
||||
from plane.db.models import Project
|
||||
from datetime import datetime, time
|
||||
from datetime import timedelta
|
||||
|
||||
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
# Create a timezone object for the user's timezone
|
||||
user_tz = pytz.timezone(user_timezone)
|
||||
|
||||
# Check if queryset is a dictionary (single item) or a list of dictionaries
|
||||
if isinstance(queryset, dict):
|
||||
queryset_values = [queryset]
|
||||
else:
|
||||
queryset_values = list(queryset)
|
||||
|
||||
# Iterate over the dictionaries in the list
|
||||
for item in queryset_values:
|
||||
# Iterate over the datetime fields
|
||||
for field in datetime_fields:
|
||||
# Convert the datetime field to the user's timezone
|
||||
if field in item and item[field]:
|
||||
item[field] = item[field].astimezone(user_tz)
|
||||
|
||||
# If queryset was a single item, return a single item
|
||||
if isinstance(queryset, dict):
|
||||
return queryset_values[0]
|
||||
else:
|
||||
return queryset_values
|
||||
|
||||
|
||||
def convert_to_utc(date, project_id, is_start_date=False):
|
||||
"""
|
||||
Converts a start date string to the project's local timezone at 12:00 AM
|
||||
and then converts it to UTC for storage.
|
||||
|
||||
Args:
|
||||
date (str): The date string in "YYYY-MM-DD" format.
|
||||
project_id (int): The project's ID to fetch the associated timezone.
|
||||
|
||||
Returns:
|
||||
datetime: The UTC datetime.
|
||||
"""
|
||||
# Retrieve the project's timezone using the project ID
|
||||
project = Project.objects.get(id=project_id)
|
||||
project_timezone = project.timezone
|
||||
if not date or not project_timezone:
|
||||
raise ValueError("Both date and timezone must be provided.")
|
||||
|
||||
# Parse the string into a date object
|
||||
start_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
|
||||
# Get the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
|
||||
# Combine the date with 12:00 AM time
|
||||
local_datetime = datetime.combine(start_date, time.min)
|
||||
|
||||
# Localize the datetime to the project's timezone
|
||||
localized_datetime = local_tz.localize(local_datetime)
|
||||
|
||||
# If it's an start date, add one minute
|
||||
if is_start_date:
|
||||
localized_datetime += timedelta(minutes=1)
|
||||
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
|
||||
# Return the UTC datetime for storage
|
||||
return utc_datetime
|
||||
|
||||
|
||||
def convert_utc_to_project_timezone(utc_datetime, project_id):
|
||||
"""
|
||||
Converts a UTC datetime (stored in the database) to the project's local timezone.
|
||||
|
||||
Args:
|
||||
utc_datetime (datetime): The UTC datetime to be converted.
|
||||
project_id (int): The project's ID to fetch the associated timezone.
|
||||
|
||||
Returns:
|
||||
datetime: The datetime in the project's local timezone.
|
||||
"""
|
||||
# Retrieve the project's timezone using the project ID
|
||||
project = Project.objects.get(id=project_id)
|
||||
project_timezone = project.timezone
|
||||
if not project_timezone:
|
||||
raise ValueError("Project timezone must be provided.")
|
||||
|
||||
# Get the timezone object for the project's timezone
|
||||
local_tz = pytz.timezone(project_timezone)
|
||||
|
||||
# Convert the UTC datetime to the project's local timezone
|
||||
if utc_datetime.tzinfo is None:
|
||||
# Localize UTC datetime if it's naive (i.e., without timezone info)
|
||||
utc_datetime = pytz.utc.localize(utc_datetime)
|
||||
|
||||
# Convert to the project's local timezone
|
||||
local_datetime = utc_datetime.astimezone(local_tz)
|
||||
|
||||
return local_datetime
|
||||
@@ -1,26 +0,0 @@
|
||||
import pytz
|
||||
|
||||
|
||||
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
# Create a timezone object for the user's timezone
|
||||
user_tz = pytz.timezone(user_timezone)
|
||||
|
||||
# Check if queryset is a dictionary (single item) or a list of dictionaries
|
||||
if isinstance(queryset, dict):
|
||||
queryset_values = [queryset]
|
||||
else:
|
||||
queryset_values = list(queryset)
|
||||
|
||||
# Iterate over the dictionaries in the list
|
||||
for item in queryset_values:
|
||||
# Iterate over the datetime fields
|
||||
for field in datetime_fields:
|
||||
# Convert the datetime field to the user's timezone
|
||||
if field in item and item[field]:
|
||||
item[field] = item[field].astimezone(user_tz)
|
||||
|
||||
# If queryset was a single item, return a single item
|
||||
if isinstance(queryset, dict):
|
||||
return queryset_values[0]
|
||||
else:
|
||||
return queryset_values
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.16
|
||||
Django==4.2.17
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "Github Client Secret",
|
||||
"description": "GitHub Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
|
||||
@@ -62,7 +62,7 @@ mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
@@ -1,54 +1,63 @@
|
||||
x-app-env: &app-env
|
||||
environment:
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- WEB_URL=${WEB_URL:-http://localhost}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
|
||||
#DB SETTINGS
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-plane}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-plane}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
# REDIS SETTINGS
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
|
||||
x-db-env: &db-env
|
||||
PGHOST: ${PGHOST:-plane-db}
|
||||
PGDATABASE: ${PGDATABASE:-plane}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-plane}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-plane}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data}
|
||||
|
||||
x-redis-env: &redis-env
|
||||
REDIS_HOST: ${REDIS_HOST:-plane-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
|
||||
|
||||
x-minio-env: &minio-env
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
|
||||
x-aws-s3-env: &aws-s3-env
|
||||
AWS_REGION: ${AWS_REGION:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
x-proxy-env: &proxy-env
|
||||
NGINX_PORT: ${NGINX_PORT:-80}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
x-mq-env: &mq-env
|
||||
# RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
|
||||
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
|
||||
RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
|
||||
|
||||
x-live-env: &live-env
|
||||
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
||||
|
||||
x-app-env: &app-env
|
||||
WEB_URL: ${WEB_URL:-http://localhost}
|
||||
DEBUG: ${DEBUG:-0}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
GUNICORN_WORKERS: 1
|
||||
USE_MINIO: ${USE_MINIO:-1}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
|
||||
SPACE_BASE_URL: ${SPACE_BASE_URL}
|
||||
APP_BASE_URL: ${APP_BASE_URL}
|
||||
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
|
||||
# RabbitMQ Settings
|
||||
- RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq}
|
||||
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane}
|
||||
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane}
|
||||
- RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane}
|
||||
- AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
# Application secret
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
# DATA STORE SETTINGS
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
- AWS_REGION=${AWS_REGION:-}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
|
||||
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
|
||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
# Live server env
|
||||
- API_BASE_URL=${API_BASE_URL:-http://api:8000}
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -61,7 +70,6 @@ services:
|
||||
- worker
|
||||
|
||||
space:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -75,7 +83,6 @@ services:
|
||||
- web
|
||||
|
||||
admin:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -88,12 +95,13 @@ services:
|
||||
- web
|
||||
|
||||
live:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node live/dist/server.js live
|
||||
environment:
|
||||
<<: [ *live-env ]
|
||||
deploy:
|
||||
replicas: ${LIVE_REPLICAS:-1}
|
||||
depends_on:
|
||||
@@ -101,7 +109,6 @@ services:
|
||||
- web
|
||||
|
||||
api:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -111,14 +118,14 @@ services:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
volumes:
|
||||
- logs_api:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
- plane-mq
|
||||
|
||||
|
||||
worker:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -126,6 +133,8 @@ services:
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
volumes:
|
||||
- logs_worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -133,7 +142,6 @@ services:
|
||||
- plane-mq
|
||||
|
||||
beat-worker:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -141,6 +149,8 @@ services:
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
volumes:
|
||||
- logs_beat-worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -148,7 +158,6 @@ services:
|
||||
- plane-mq
|
||||
|
||||
migrator:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -156,21 +165,23 @@ services:
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
volumes:
|
||||
- logs_migrator:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-db:
|
||||
<<: *app-env
|
||||
image: postgres:15.7-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: postgres -c 'max_connections=1000'
|
||||
environment:
|
||||
<<: *db-env
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
plane-redis:
|
||||
<<: *app-env
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
@@ -178,30 +189,33 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
plane-mq:
|
||||
<<: *app-env
|
||||
image: rabbitmq:3.13.6-management-alpine
|
||||
restart: always
|
||||
environment:
|
||||
<<: *mq-env
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
|
||||
plane-minio:
|
||||
<<: *app-env
|
||||
image: minio/minio:latest
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: server /export --console-address ":9090"
|
||||
environment:
|
||||
<<: *minio-env
|
||||
volumes:
|
||||
- uploads:/export
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
|
||||
+97
-13
@@ -4,9 +4,12 @@ BRANCH=${BRANCH:-master}
|
||||
SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE="stable"
|
||||
export APP_RELEASE=stable
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=${PULL_POLICY:-if_not_present}
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
|
||||
|
||||
CPU_ARCH=$(uname -m)
|
||||
OS_NAME=$(uname)
|
||||
@@ -16,13 +19,6 @@ mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
SED_PREFIX=()
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
SED_PREFIX=("-i" "")
|
||||
else
|
||||
SED_PREFIX=("-i")
|
||||
fi
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
@@ -59,6 +55,17 @@ function spinner() {
|
||||
printf " \b\b\b\b" >&2
|
||||
}
|
||||
|
||||
function checkLatestRelease(){
|
||||
echo "Checking for the latest release..." >&2
|
||||
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
|
||||
if [ -z "$latest_release" ]; then
|
||||
echo "Failed to check for the latest release. Exiting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo $latest_release
|
||||
}
|
||||
|
||||
function initialize(){
|
||||
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
|
||||
|
||||
@@ -130,8 +137,12 @@ function updateEnvFile() {
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
# if key exists, update the value
|
||||
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
else
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
@@ -182,7 +193,7 @@ function buildYourOwnImage(){
|
||||
local PLANE_TEMP_CODE_DIR=~/tmp/plane
|
||||
rm -rf $PLANE_TEMP_CODE_DIR
|
||||
mkdir -p $PLANE_TEMP_CODE_DIR
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
REPO=https://github.com/$GH_REPO.git
|
||||
git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1
|
||||
|
||||
cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml"
|
||||
@@ -204,6 +215,10 @@ function install() {
|
||||
echo "Begin Installing Plane"
|
||||
echo ""
|
||||
|
||||
if [ "$APP_RELEASE" == "stable" ]; then
|
||||
export APP_RELEASE=$(checkLatestRelease)
|
||||
fi
|
||||
|
||||
local build_image=$(initialize)
|
||||
|
||||
if [ "$build_image" == "build" ]; then
|
||||
@@ -232,8 +247,49 @@ function download() {
|
||||
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
|
||||
fi
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
else
|
||||
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
echo "Failed to download variables.env. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DOCKER_ENV_PATH" ];
|
||||
then
|
||||
@@ -335,6 +391,34 @@ function restartServices() {
|
||||
startServices
|
||||
}
|
||||
function upgrade() {
|
||||
local latest_release=$(checkLatestRelease)
|
||||
|
||||
echo ""
|
||||
echo "Current release: $APP_RELEASE"
|
||||
|
||||
if [ "$latest_release" == "$APP_RELEASE" ]; then
|
||||
echo ""
|
||||
echo "You are already using the latest release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Latest release: $latest_release"
|
||||
echo ""
|
||||
|
||||
# Check for confirmation to upgrade
|
||||
echo "Do you want to upgrade to the latest release ($latest_release)?"
|
||||
read -p "Continue? [y/N]: " confirm
|
||||
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export APP_RELEASE=$latest_release
|
||||
|
||||
echo "Upgrading Plane to the latest release..."
|
||||
echo ""
|
||||
|
||||
echo "***** STOPPING SERVICES ****"
|
||||
stopServices
|
||||
|
||||
|
||||
@@ -47,9 +47,6 @@ AWS_ACCESS_KEY_ID=access-key
|
||||
AWS_SECRET_ACCESS_KEY=secret-key
|
||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||
AWS_S3_BUCKET_NAME=uploads
|
||||
MINIO_ROOT_USER=access-key
|
||||
MINIO_ROOT_PASSWORD=secret-key
|
||||
BUCKET_NAME=uploads
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# Gunicorn Workers
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -30,7 +30,7 @@
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.20.0",
|
||||
"express": "^4.21.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
|
||||
@@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
import {
|
||||
DocumentCollaborativeEvents,
|
||||
TDocumentEventsServer,
|
||||
} from "@plane/editor/lib";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
// types
|
||||
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
async onStateless({ payload, document }) {
|
||||
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||
const response =
|
||||
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
debounce: 10000,
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
"main": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
||||
// PI Base Url
|
||||
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
|
||||
// God Mode Admin App Base Url
|
||||
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
|
||||
// Publish App Base Url
|
||||
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
|
||||
// Live App Base Url
|
||||
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
|
||||
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
|
||||
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
|
||||
// plane website url
|
||||
export const WEBSITE_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./endpoints";
|
||||
export * from "./issue";
|
||||
export * from "./workspace";
|
||||
@@ -11,6 +11,7 @@ export enum EIssueGroupByToServerOptions {
|
||||
"target_date" = "target_date",
|
||||
"project" = "project_id",
|
||||
"created_by" = "created_by",
|
||||
"team_project" = "project_id",
|
||||
}
|
||||
|
||||
export enum EIssueGroupBYServerToProperty {
|
||||
@@ -38,3 +39,8 @@ export enum EServerGroupByToFilterOptions {
|
||||
"project_id" = "project",
|
||||
"created_by" = "created_by",
|
||||
}
|
||||
|
||||
export enum EIssueServiceType {
|
||||
ISSUES = "issues",
|
||||
EPICS = "epics",
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const ORGANIZATION_SIZE = [
|
||||
"Just myself",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-500",
|
||||
"500+",
|
||||
];
|
||||
|
||||
export const RESTRICTED_URLS = [
|
||||
"404",
|
||||
"accounts",
|
||||
"api",
|
||||
"create-workspace",
|
||||
"god-mode",
|
||||
"installations",
|
||||
"invitations",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"spaces",
|
||||
"workspace-invitations",
|
||||
"password",
|
||||
"flags",
|
||||
"monitor",
|
||||
"monitoring",
|
||||
"ingest",
|
||||
"plane-pro",
|
||||
"plane-ultimate",
|
||||
"enterprise",
|
||||
"plane-enterprise",
|
||||
"disco",
|
||||
"silo",
|
||||
"chat",
|
||||
"calendar",
|
||||
"drive",
|
||||
"channels",
|
||||
"upgrade",
|
||||
"billing",
|
||||
"sign-in",
|
||||
"sign-up",
|
||||
"signin",
|
||||
"signup",
|
||||
"config",
|
||||
"live",
|
||||
"admin",
|
||||
"m",
|
||||
"import",
|
||||
"importers",
|
||||
"integrations",
|
||||
"integration",
|
||||
"configuration",
|
||||
"initiatives",
|
||||
"initiative",
|
||||
"config",
|
||||
"workflow",
|
||||
"workflows",
|
||||
"epics",
|
||||
"epic",
|
||||
"story",
|
||||
"mobile",
|
||||
"dashboard",
|
||||
"desktop",
|
||||
"onload",
|
||||
"real-time",
|
||||
"one",
|
||||
"pages",
|
||||
"mobile",
|
||||
"business",
|
||||
"pro",
|
||||
"settings",
|
||||
"monitor",
|
||||
"license",
|
||||
"licenses",
|
||||
"instances",
|
||||
"instance",
|
||||
];
|
||||
@@ -1,23 +0,0 @@
|
||||
export const ORGANIZATION_SIZE = [
|
||||
"Just myself",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-500",
|
||||
"500+",
|
||||
];
|
||||
|
||||
export const RESTRICTED_URLS = [
|
||||
"404",
|
||||
"accounts",
|
||||
"api",
|
||||
"create-workspace",
|
||||
"error",
|
||||
"god-mode",
|
||||
"installations",
|
||||
"invitations",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"spaces",
|
||||
"workspace-invitations",
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -27,6 +27,7 @@
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -36,8 +37,8 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-character-count": "^2.6.5",
|
||||
@@ -56,7 +57,6 @@
|
||||
"@tiptap/starter-kit": "^2.1.13",
|
||||
"@tiptap/suggestion": "^2.0.13",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"highlight.js": "^11.8.0",
|
||||
"jsx-dom-cjs": "^8.0.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
@@ -65,7 +65,6 @@
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"uuid": "^10.0.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./extensions";
|
||||
export * from "./read-only-extensions";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
|
||||
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
|
||||
@@ -15,7 +15,13 @@ type Props = {
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions } = _props;
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
|
||||
? []
|
||||
: [
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./core";
|
||||
export * from "./document-extensions";
|
||||
export * from "./slash-commands";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// extensions
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
const options: TSlashCommandAdditionalOption[] = [];
|
||||
return options;
|
||||
};
|
||||
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
@@ -44,8 +45,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
onTransaction,
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
extensions,
|
||||
@@ -54,6 +55,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
user,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
id={id}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef<
|
||||
EditorReadOnlyRefApi,
|
||||
ICollaborativeDocumentReadOnlyEditor
|
||||
>((props, ref) => (
|
||||
<CollaborativeDocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { CollaborativeDocumentReadOnlyEditorWithRef };
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./collaborative-editor";
|
||||
export * from "./collaborative-read-only-editor";
|
||||
export * from "./loader";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
|
||||
@@ -140,10 +140,10 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && (
|
||||
<>
|
||||
<div>
|
||||
<BlockMenu editor={editor} />
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
@@ -31,6 +32,7 @@ interface IDocumentReadOnlyEditor {
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
}
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
const {
|
||||
children,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
@@ -37,6 +38,8 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
editable: true,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types";
|
||||
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
fileHandler,
|
||||
@@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
} = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
|
||||
@@ -8,12 +8,7 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
} = props;
|
||||
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
@@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
}),
|
||||
];
|
||||
if (!disabledExtensions?.includes("slash-commands")) {
|
||||
extensions.push(SlashCommands());
|
||||
extensions.push(
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
import { TAIHandler } from "@/types";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { AlignCenter, AlignLeft, AlignRight, LucideIcon } from "lucide-react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { TextAlignItem } from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ALargeSmall, Ban } from "lucide-react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Link, Trash } from "lucide-react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
BulletListItem,
|
||||
@@ -17,8 +19,6 @@ import {
|
||||
HeadingSixItem,
|
||||
EditorMenuItem,
|
||||
} from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
BoldItem,
|
||||
@@ -13,8 +15,6 @@ import {
|
||||
} from "@/components/menus";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// local components
|
||||
import { TextAlignmentSelector } from "./alignment-selector";
|
||||
|
||||
|
||||
@@ -5,3 +5,6 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||
fontSize: "large-font",
|
||||
fontStyle: "sans-serif",
|
||||
};
|
||||
|
||||
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
||||
export const ACCEPTED_FILE_EXTENSIONS = ACCEPTED_FILE_MIME_TYPES.map((type) => `.${type.split("/")[1]}`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user