Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6fd905644 |
@@ -273,7 +273,7 @@ jobs:
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.yarn
|
||||
|
||||
### NextJS ###
|
||||
# Dependencies
|
||||
@@ -79,17 +78,10 @@ pnpm-workspace.yaml
|
||||
.npmrc
|
||||
.secrets
|
||||
tmp/
|
||||
|
||||
## packages
|
||||
dist
|
||||
.temp/
|
||||
deploy/selfhost/plane-app/
|
||||
|
||||
## Storybook
|
||||
*storybook.log
|
||||
output.css
|
||||
|
||||
dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
+10
-40
@@ -15,33 +15,14 @@ Without said minimal reproduction, we won't be able to investigate all [issues](
|
||||
|
||||
You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new).
|
||||
|
||||
### Naming conventions for issues
|
||||
|
||||
When opening a new issue, please use a clear and concise title that follows this format:
|
||||
|
||||
- For bugs: `🐛 Bug: [short description]`
|
||||
- For features: `🚀 Feature: [short description]`
|
||||
- For improvements: `🛠️ Improvement: [short description]`
|
||||
- For documentation: `📘 Docs: [short description]`
|
||||
|
||||
**Examples:**
|
||||
- `🐛 Bug: API token expiry time not saving correctly`
|
||||
- `📘 Docs: Clarify RAM requirement for local setup`
|
||||
- `🚀 Feature: Allow custom time selection for token expiration`
|
||||
|
||||
This helps us triage and manage issues more efficiently.
|
||||
|
||||
## Projects setup and Architecture
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker Engine installed and running
|
||||
- Node.js version 20+ [LTS version](https://nodejs.org/en/about/previous-releases)
|
||||
- Node.js version v16.18.0
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
- **Memory**: Minimum **12 GB RAM** recommended
|
||||
> ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible.
|
||||
|
||||
### Setup the project
|
||||
|
||||
@@ -69,17 +50,6 @@ chmod +x setup.sh
|
||||
docker compose -f docker-compose-local.yml up
|
||||
```
|
||||
|
||||
5. Start web apps:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
## Missing a Feature?
|
||||
|
||||
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
|
||||
@@ -105,7 +75,7 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
## Contributing to language support
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
|
||||
### Understanding translation structure
|
||||
|
||||
@@ -120,7 +90,7 @@ packages/i18n/src/locales/
|
||||
├── fr/
|
||||
│ └── translations.json
|
||||
└── [language]/
|
||||
└── translations.json
|
||||
└── translations.json
|
||||
```
|
||||
#### Nested structure
|
||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||
@@ -140,14 +110,14 @@ To keep translations organized, we use a nested structure for keys. This makes i
|
||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||
|
||||
#### Examples
|
||||
- **Simple variables**
|
||||
- **Simple variables**
|
||||
```json
|
||||
{
|
||||
"greeting": "Hello, {name}!"
|
||||
}
|
||||
```
|
||||
|
||||
- **Pluralization**
|
||||
- **Pluralization**
|
||||
```json
|
||||
{
|
||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||
@@ -172,15 +142,15 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/)
|
||||
### Adding new languages
|
||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||
|
||||
1. **Update type definitions**
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```typescript
|
||||
// types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
```
|
||||
|
||||
2. **Add language configuration**
|
||||
2. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
```typescript
|
||||
@@ -191,14 +161,14 @@ Include the new language in the list of supported languages:
|
||||
];
|
||||
```
|
||||
|
||||
3. **Create translation files**
|
||||
3. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
|
||||
2. Add a `translations.json` file inside the folder.
|
||||
|
||||
3. Copy the structure from an existing translation file and translate all keys.
|
||||
|
||||
4. **Update import logic**
|
||||
4. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -43,6 +43,9 @@ NGINX_PORT=80
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
# Database Settings
|
||||
POSTGRES_USER="plane"
|
||||
POSTGRES_PASSWORD="plane"
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so/"><b>Website</b></a> •
|
||||
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
|
||||
<a href="https://git.new/releases"><b>Releases</b></a> •
|
||||
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
|
||||
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -39,7 +39,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
|
||||
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
|
||||
|
||||
> 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.
|
||||
|
||||
@@ -47,10 +47,10 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track
|
||||
|
||||
Getting started with Plane is simple. Choose the setup that works best for you:
|
||||
|
||||
- **Plane Cloud**
|
||||
- **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**
|
||||
- **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 |
|
||||
@@ -62,22 +62,22 @@ Prefer full control over your data and infrastructure? Install and run Plane on
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- **Issues**
|
||||
- **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**
|
||||
- **Cycles**
|
||||
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
||||
|
||||
- **Modules**
|
||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||
- **Modules**
|
||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||
|
||||
- **Views**
|
||||
- **Views**
|
||||
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
||||
|
||||
- **Pages**
|
||||
- **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**
|
||||
- **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.
|
||||
@@ -85,7 +85,38 @@ Access real-time insights across all your Plane data. Visualize trends, remove b
|
||||
|
||||
## 🛠️ Local development
|
||||
|
||||
See [CONTRIBUTING](./CONTRIBUTING.md)
|
||||
### Pre-requisites
|
||||
- Ensure Docker Engine is installed and running.
|
||||
|
||||
### 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. Navigate to the project folder:
|
||||
```
|
||||
cd plane
|
||||
```
|
||||
3. Create a new branch for your feature or fix:
|
||||
```
|
||||
git checkout -b <feature-branch-name>
|
||||
```
|
||||
4. Run the setup script in the terminal:
|
||||
```
|
||||
./setup.sh
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
## ⚙️ Built with
|
||||
[](https://nextjs.org/)
|
||||
@@ -163,7 +194,7 @@ Feel free to ask questions, report bugs, participate in discussions, share ideas
|
||||
|
||||
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.
|
||||
To disclose any security issues, please email us at security@plane.so.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -188,4 +219,4 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
|
||||
|
||||
|
||||
## License
|
||||
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
||||
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
||||
+2
-11
@@ -1,12 +1,3 @@
|
||||
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
|
||||
|
||||
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
@@ -43,7 +43,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||
defaultValues: {
|
||||
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
|
||||
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
|
||||
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,19 +93,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||
error: Boolean(errors.GITHUB_CLIENT_SECRET),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "GITHUB_ORGANIZATION_ID",
|
||||
type: "text",
|
||||
label: "Organization ID",
|
||||
description: (
|
||||
<>
|
||||
The organization github ID.
|
||||
</>
|
||||
),
|
||||
placeholder: "123456789",
|
||||
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const GITHUB_SERVICE_FIELD: TCopyField[] = [
|
||||
@@ -164,7 +150,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||
reset({
|
||||
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
|
||||
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
|
||||
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
@@ -9,19 +9,6 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
|
||||
transpilePackages: [
|
||||
"@plane/constants",
|
||||
"@plane/editor",
|
||||
"@plane/hooks",
|
||||
"@plane/i18n",
|
||||
"@plane/logger",
|
||||
"@plane/propel",
|
||||
"@plane/services",
|
||||
"@plane/shared-state",
|
||||
"@plane/types",
|
||||
"@plane/ui",
|
||||
"@plane/utils",
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.26.0",
|
||||
"version": "0.25.3",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -17,10 +17,11 @@
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/services": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@plane/services": "*",
|
||||
"@sentry/nextjs": "^8.54.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
@@ -29,7 +30,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.20",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -145,8 +145,11 @@ RUN chmod +x /app/pg-setup.sh
|
||||
# APPLICATION ENVIRONMENT SETTINGS
|
||||
# *****************************************************************************
|
||||
ENV APP_DOMAIN=localhost
|
||||
|
||||
ENV WEB_URL=http://${APP_DOMAIN}
|
||||
ENV DEBUG=0
|
||||
ENV SENTRY_DSN=
|
||||
ENV SENTRY_ENVIRONMENT=production
|
||||
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
|
||||
# Secret Key
|
||||
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||
|
||||
+11
-16
@@ -1,7 +1,11 @@
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100"
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
|
||||
# Database Settings
|
||||
POSTGRES_USER="plane"
|
||||
@@ -27,7 +31,7 @@ RABBITMQ_VHOST="plane"
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://localhost:9000"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
@@ -37,31 +41,22 @@ FILE_SIZE_LIMIT=5242880
|
||||
DOCKERIZED=1 # deprecated
|
||||
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=0
|
||||
USE_MINIO=1
|
||||
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost:8000"
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
# Base URLs
|
||||
ADMIN_BASE_URL="http://localhost:3001"
|
||||
ADMIN_BASE_PATH="/god-mode"
|
||||
ADMIN_BASE_URL=
|
||||
SPACE_BASE_URL=
|
||||
APP_BASE_URL=
|
||||
|
||||
SPACE_BASE_URL="http://localhost:3002"
|
||||
SPACE_BASE_PATH="/spaces"
|
||||
|
||||
APP_BASE_URL="http://localhost:3000"
|
||||
APP_BASE_PATH=""
|
||||
|
||||
LIVE_BASE_URL="http://localhost:3100"
|
||||
LIVE_BASE_PATH="/live"
|
||||
|
||||
LIVE_SERVER_SECRET_KEY="secret-key"
|
||||
|
||||
# Hard delete files after days
|
||||
HARD_DELETE_AFTER_DAYS=60
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.26.0",
|
||||
"version": "0.25.3",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Third party imports
|
||||
import pytz
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
@@ -19,14 +18,6 @@ class CycleSerializer(BaseSerializer):
|
||||
completed_estimates = serializers.FloatField(read_only=True)
|
||||
started_estimates = serializers.FloatField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
project = self.context.get("project")
|
||||
if project and project.timezone:
|
||||
project_timezone = pytz.timezone(project.timezone)
|
||||
self.fields["start_date"].timezone = project_timezone
|
||||
self.fields["end_date"].timezone = project_timezone
|
||||
|
||||
def validate(self, data):
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
@@ -39,15 +30,12 @@ class CycleSerializer(BaseSerializer):
|
||||
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
|
||||
if self.instance and hasattr(self.instance, "project_id")
|
||||
else None
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
else False
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
|
||||
data["start_date"] = convert_to_utc(
|
||||
date=str(data.get("start_date").date()),
|
||||
project_id=project_id,
|
||||
@@ -56,6 +44,7 @@ class CycleSerializer(BaseSerializer):
|
||||
data["end_date"] = convert_to_utc(
|
||||
date=str(data.get("end_date", None).date()),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ProjectSerializer(BaseSerializer):
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
is_deployed = serializers.BooleanField(read_only=True)
|
||||
cover_image_url = serializers.CharField(read_only=True)
|
||||
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
||||
@@ -4,6 +4,16 @@ from plane.api.views import IntakeIssueAPIEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
|
||||
IntakeIssueAPIEndpoint.as_view(),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
|
||||
IntakeIssueAPIEndpoint.as_view(),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/",
|
||||
IntakeIssueAPIEndpoint.as_view(),
|
||||
|
||||
@@ -39,7 +39,7 @@ from plane.db.models import (
|
||||
UserFavorite,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
@@ -137,14 +137,10 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
if pk:
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
queryset, fields=self.fields, expand=self.expand
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
@@ -156,11 +152,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
queryset, many=True, fields=self.fields, expand=self.expand
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -171,11 +163,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -186,11 +174,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -201,11 +185,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -218,22 +198,14 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -279,7 +251,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -351,7 +323,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -722,7 +694,7 @@ class CycleIssueAPIEndpoint(BaseAPIView):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# Return all Cycle Issues
|
||||
return Response(
|
||||
@@ -788,7 +760,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -800,7 +771,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -849,7 +819,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
@@ -969,7 +938,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
@@ -1117,7 +1086,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
@@ -1129,12 +1098,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"total_issues": old_cycle.first().total_issues,
|
||||
"completed_issues": old_cycle.first().completed_issues,
|
||||
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||
"started_issues": old_cycle.first().started_issues,
|
||||
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||
"backlog_issues": old_cycle.first().backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
@@ -1199,7 +1168,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -18,9 +18,8 @@ from plane.api.serializers import IntakeIssueSerializer, IssueSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
|
||||
class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
@@ -126,7 +125,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
intake_id=intake.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=SourceType.IN_APP,
|
||||
source=request.data.get("source", "IN-APP"),
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
@@ -298,7 +297,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@ from plane.db.models import (
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
@@ -323,17 +321,6 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1061,7 +1048,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Get the storage metadata
|
||||
@@ -1121,7 +1108,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
|
||||
@@ -33,7 +33,6 @@ from plane.db.models import (
|
||||
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleAPIEndpoint(BaseAPIView):
|
||||
@@ -175,7 +174,7 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
module = Module.objects.get(pk=serializer.data["id"])
|
||||
serializer = ModuleSerializer(module)
|
||||
@@ -227,7 +226,7 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -281,7 +280,6 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps({"module_name": str(module.name)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
module.delete()
|
||||
# Delete the module issues
|
||||
@@ -451,7 +449,6 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -30,7 +30,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectAPIEndpoint(BaseAPIView):
|
||||
@@ -172,14 +171,14 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"color": "#3A3A3A",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
@@ -191,13 +190,13 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"color": "#16A34A",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
@@ -229,7 +228,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
@@ -239,7 +238,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -248,7 +247,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
@@ -259,7 +258,9 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
intake_view = request.data.get("intake_view", project.intake_view)
|
||||
intake_view = request.data.get(
|
||||
"inbox_view", request.data.get("intake_view", project.intake_view)
|
||||
)
|
||||
|
||||
if project.archived_at:
|
||||
return Response(
|
||||
@@ -296,7 +297,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
@@ -306,7 +307,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -315,7 +316,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
@@ -333,7 +334,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
new_value=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
current_site=base_host(request=request, is_app=True),
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
event_id=project.id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import APIToken, APIActivityLog
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
@@ -19,17 +17,10 @@ class APITokenSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class APITokenReadSerializer(BaseSerializer):
|
||||
is_active = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = APIToken
|
||||
exclude = ("token",)
|
||||
|
||||
def get_is_active(self, obj: APIToken) -> bool:
|
||||
if obj.expired_at is None:
|
||||
return True
|
||||
return timezone.now() < obj.expired_at
|
||||
|
||||
|
||||
class APIActivityLogSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -25,6 +25,11 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
or (self.instance and self.instance.project_id)
|
||||
or self.context.get("project_id", None)
|
||||
)
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
else False
|
||||
)
|
||||
data["start_date"] = convert_to_utc(
|
||||
date=str(data.get("start_date").date()),
|
||||
project_id=project_id,
|
||||
@@ -33,6 +38,7 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
data["end_date"] = convert_to_utc(
|
||||
date=str(data.get("end_date", None).date()),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -352,19 +352,8 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
@@ -394,19 +383,8 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
class IssueAssigneeSerializer(BaseSerializer):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
from plane.db.models import State
|
||||
|
||||
|
||||
class StateSerializer(BaseSerializer):
|
||||
order = serializers.FloatField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = State
|
||||
fields = [
|
||||
@@ -20,7 +18,6 @@ class StateSerializer(BaseSerializer):
|
||||
"default",
|
||||
"description",
|
||||
"sequence",
|
||||
"order",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
IntakeViewSet,
|
||||
IntakeIssueViewSet,
|
||||
IntakeWorkItemDescriptionVersionEndpoint,
|
||||
)
|
||||
from plane.app.views import IntakeViewSet, IntakeIssueViewSet
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -57,14 +53,4 @@ urlpatterns = [
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/",
|
||||
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
|
||||
name="intake-work-item-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
|
||||
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
|
||||
name="intake-work-item-versions",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ from plane.app.views import (
|
||||
IssueAttachmentV2Endpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueVersionEndpoint,
|
||||
WorkItemDescriptionVersionEndpoint,
|
||||
IssueDescriptionVersionEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
@@ -263,22 +263,22 @@ urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="issue-versions",
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="issue-versions",
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/",
|
||||
WorkItemDescriptionVersionEndpoint.as_view(),
|
||||
name="work-item-versions",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
|
||||
WorkItemDescriptionVersionEndpoint.as_view(),
|
||||
name="work-item-versions",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
|
||||
|
||||
@@ -144,7 +144,7 @@ from .issue.sub_issue import SubIssuesEndpoint
|
||||
|
||||
from .issue.subscriber import IssueSubscriberViewSet
|
||||
|
||||
from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint
|
||||
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
|
||||
|
||||
from .module.base import (
|
||||
ModuleViewSet,
|
||||
@@ -184,11 +184,7 @@ from .estimate.base import (
|
||||
EstimatePointEndpoint,
|
||||
)
|
||||
|
||||
from .intake.base import (
|
||||
IntakeViewSet,
|
||||
IntakeIssueViewSet,
|
||||
IntakeWorkItemDescriptionVersionEndpoint,
|
||||
)
|
||||
from .intake.base import IntakeViewSet, IntakeIssueViewSet
|
||||
|
||||
from .analytic.base import (
|
||||
AnalyticsEndpoint,
|
||||
|
||||
@@ -9,11 +9,11 @@ from rest_framework import status
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.app.permissions import WorkspaceOwnerPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
permission_classes = [WorkspaceOwnerPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
@@ -68,7 +68,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
permission_classes = [WorkspaceOwnerPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
@@ -137,7 +137,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -351,7 +351,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -552,7 +552,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -683,7 +683,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id, project_id=project_id)
|
||||
assets.update(issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
|
||||
@@ -117,7 +118,6 @@ class CycleViewSet(BaseViewSet):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -130,7 +130,6 @@ class CycleViewSet(BaseViewSet):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -143,7 +142,6 @@ class CycleViewSet(BaseViewSet):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -269,7 +267,9 @@ class CycleViewSet(BaseViewSet):
|
||||
"created_by",
|
||||
)
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, project_timezone)
|
||||
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])
|
||||
@@ -318,13 +318,9 @@ class CycleViewSet(BaseViewSet):
|
||||
.first()
|
||||
)
|
||||
|
||||
# Fetch the project timezone
|
||||
project = Project.objects.get(id=self.kwargs.get("project_id"))
|
||||
project_timezone = project.timezone
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, project_timezone
|
||||
cycle, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
@@ -335,7 +331,7 @@ class CycleViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(cycle, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -411,12 +407,10 @@ class CycleViewSet(BaseViewSet):
|
||||
"created_by",
|
||||
).first()
|
||||
|
||||
# Fetch the project timezone
|
||||
project = Project.objects.get(id=self.kwargs.get("project_id"))
|
||||
project_timezone = project.timezone
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
@@ -426,7 +420,7 @@ class CycleViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(cycle, status=status.HTTP_200_OK)
|
||||
@@ -486,11 +480,10 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
queryset = queryset.first()
|
||||
# Fetch the project timezone
|
||||
project = Project.objects.get(id=self.kwargs.get("project_id"))
|
||||
project_timezone = project.timezone
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
data = user_timezone_converter(data, datetime_fields, project_timezone)
|
||||
data = user_timezone_converter(
|
||||
data, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
@@ -539,7 +532,7 @@ class CycleViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# TODO: Soft delete the cycle break the onetoone relationship with cycle issue
|
||||
cycle.delete()
|
||||
@@ -573,12 +566,16 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
is_start_date_end_date_equal = (
|
||||
True if str(start_date) == str(end_date) else False
|
||||
)
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_date), project_id=project_id, is_start_date=True
|
||||
)
|
||||
end_date = convert_to_utc(
|
||||
date=str(end_date),
|
||||
project_id=project_id,
|
||||
is_start_date_end_date_equal=is_start_date_end_date_equal,
|
||||
)
|
||||
|
||||
# Check if any cycle intersects in the given interval
|
||||
@@ -663,7 +660,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -728,7 +724,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
@@ -847,7 +842,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
@@ -994,7 +989,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
@@ -1006,12 +1001,12 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"total_issues": old_cycle.first().total_issues,
|
||||
"completed_issues": old_cycle.first().completed_issues,
|
||||
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||
"started_issues": old_cycle.first().started_issues,
|
||||
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||
"backlog_issues": old_cycle.first().backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
@@ -1076,7 +1071,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
@@ -1119,14 +1114,6 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
class CycleProgressEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
).first()
|
||||
if not cycle:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -1177,60 +1164,53 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0)
|
||||
unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0)
|
||||
started_issues = cycle.progress_snapshot.get("started_issues", 0)
|
||||
cancelled_issues = cycle.progress_snapshot.get("cancelled_issues", 0)
|
||||
completed_issues = cycle.progress_snapshot.get("completed_issues", 0)
|
||||
total_issues = cycle.progress_snapshot.get("total_issues", 0)
|
||||
else:
|
||||
backlog_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="backlog",
|
||||
).count()
|
||||
|
||||
unstarted_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="unstarted",
|
||||
).count()
|
||||
backlog_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="backlog",
|
||||
).count()
|
||||
|
||||
started_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="started",
|
||||
).count()
|
||||
unstarted_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="unstarted",
|
||||
).count()
|
||||
|
||||
cancelled_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="cancelled",
|
||||
).count()
|
||||
started_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="started",
|
||||
).count()
|
||||
|
||||
completed_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="completed",
|
||||
).count()
|
||||
cancelled_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="cancelled",
|
||||
).count()
|
||||
|
||||
total_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).count()
|
||||
completed_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
total_issues = Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).count()
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -1291,25 +1271,6 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# this will tell whether the issues were transferred to the new cycle
|
||||
"""
|
||||
if the issues were transferred to the new cycle, then the progress_snapshot will be present
|
||||
return the progress_snapshot data in the analytics for each date
|
||||
|
||||
else issues were not transferred to the new cycle then generate the stats from the cycle isssue bridge tables
|
||||
"""
|
||||
|
||||
if cycle.progress_snapshot:
|
||||
distribution = cycle.progress_snapshot.get("distribution", {})
|
||||
return Response(
|
||||
{
|
||||
"labels": distribution.get("labels", []),
|
||||
"assignees": distribution.get("assignees", []),
|
||||
"completion_chart": distribution.get("completion_chart", {}),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
@@ -291,7 +291,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -317,7 +317,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
cycle_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -27,24 +27,16 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueSerializer,
|
||||
IntakeSerializer,
|
||||
IntakeIssueSerializer,
|
||||
IntakeIssueDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.utils.host import base_host
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
|
||||
class IntakeViewSet(BaseViewSet):
|
||||
@@ -95,7 +87,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
serializer_class = IntakeIssueSerializer
|
||||
model = IntakeIssue
|
||||
|
||||
filterset_fields = ["status"]
|
||||
filterset_fields = ["statulls"]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
@@ -226,7 +218,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=ROLE.GUEST.value,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
@@ -279,7 +271,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
intake_id=intake_id.id,
|
||||
project_id=project_id,
|
||||
issue_id=serializer.data["id"],
|
||||
source=SourceType.IN_APP,
|
||||
source=request.data.get("source", "IN-APP"),
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
@@ -291,16 +283,9 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
intake_issue = (
|
||||
IntakeIssue.objects.select_related("issue")
|
||||
.prefetch_related("issue__labels", "issue__assignees")
|
||||
@@ -400,15 +385,13 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
current_instance = json.dumps(
|
||||
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True, context={"project_id": project_id}
|
||||
)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
@@ -418,18 +401,15 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
issue_id=str(pk),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
return Response(
|
||||
@@ -487,7 +467,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
intake=(intake_issue.id),
|
||||
)
|
||||
|
||||
@@ -569,7 +549,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=ROLE.GUEST.value,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
@@ -577,7 +557,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue = IntakeIssueDetailSerializer(intake_issue).data
|
||||
return Response(issue, status=status.HTTP_200_OK)
|
||||
@@ -604,80 +584,3 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
intake_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
paginated_data = user_timezone_converter(
|
||||
paginated_data, datetime_fields, timezone
|
||||
)
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, work_item_id, pk=None):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=work_item_id
|
||||
)
|
||||
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=ROLE.GUEST.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if pk:
|
||||
issue_description_version = IssueDescriptionVersion.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=work_item_id,
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
serializer = IssueDescriptionVersionDetailSerializer(
|
||||
issue_description_version
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
cursor = request.GET.get("cursor", None)
|
||||
|
||||
required_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
|
||||
)
|
||||
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_description_versions_queryset,
|
||||
queryset=issue_description_versions_queryset,
|
||||
cursor=cursor,
|
||||
on_result=lambda results: self.process_paginated_result(
|
||||
required_fields, results, request.user.user_timezone
|
||||
),
|
||||
)
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -37,7 +37,7 @@ from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
|
||||
@@ -259,7 +259,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue.archived_at = timezone.now().date()
|
||||
issue.save()
|
||||
@@ -287,7 +287,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
@@ -333,7 +333,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue.archived_at = timezone.now().date()
|
||||
bulk_archive_issues.append(issue)
|
||||
|
||||
@@ -21,7 +21,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
@@ -48,7 +48,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -67,7 +67,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -155,7 +155,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -213,7 +213,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
|
||||
@@ -45,7 +45,6 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -61,7 +60,6 @@ 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
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
@@ -380,7 +378,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = (
|
||||
issue_queryset_grouper(
|
||||
@@ -430,7 +428,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
@@ -566,7 +564,7 @@ class IssueViewSet(BaseViewSet):
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
@@ -633,7 +631,7 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
@@ -651,7 +649,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
@@ -660,7 +658,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
@@ -692,8 +690,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance={},
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
subscriber=False,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -741,13 +738,6 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
|
||||
total_issues = len(issues)
|
||||
|
||||
# First, delete all related cycle issues
|
||||
CycleIssue.objects.filter(issue_id__in=issue_ids).delete()
|
||||
|
||||
# Then, delete all related module issues
|
||||
ModuleIssue.objects.filter(issue_id__in=issue_ids).delete()
|
||||
|
||||
# Finally, delete the issues themselves
|
||||
issues.delete()
|
||||
|
||||
return Response(
|
||||
@@ -1035,17 +1025,9 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
|
||||
"""
|
||||
Validate that start date is before target date.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
start = new_start or current_start
|
||||
target = new_target or current_target
|
||||
|
||||
# Convert string dates to datetime objects if they're strings
|
||||
if isinstance(start, str):
|
||||
start = datetime.strptime(start, "%Y-%m-%d").date()
|
||||
if isinstance(target, str):
|
||||
target = datetime.strptime(target, "%Y-%m-%d").date()
|
||||
|
||||
if start and target and start > target:
|
||||
return False
|
||||
return True
|
||||
@@ -1287,7 +1269,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
|
||||
@@ -17,7 +17,7 @@ from plane.app.serializers import IssueCommentSerializer, CommentReactionSeriali
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
@@ -87,7 +87,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -105,13 +105,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
issue_comment, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
"comment_html" in request.data
|
||||
and request.data["comment_html"] != issue_comment.comment_html
|
||||
):
|
||||
serializer.save(edited_at=timezone.now())
|
||||
else:
|
||||
serializer.save()
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="comment.activity.updated",
|
||||
requested_data=requested_data,
|
||||
@@ -121,7 +115,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -144,7 +138,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -188,7 +182,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -222,7 +216,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -15,7 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
@@ -52,7 +52,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -77,7 +77,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -98,7 +98,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue_link.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -15,7 +15,7 @@ from plane.app.serializers import IssueReactionSerializer
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import IssueReaction
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueReactionViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
@@ -53,7 +53,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -78,7 +78,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -27,7 +27,7 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueRelationViewSet(BaseViewSet):
|
||||
serializer_class = IssueRelationSerializer
|
||||
@@ -253,7 +253,7 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if relation_type in ["blocking", "start_after", "finish_after"]:
|
||||
@@ -290,6 +290,6 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -22,8 +22,7 @@ from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
@@ -103,15 +102,6 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
# Ordering
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
|
||||
if order_by_param:
|
||||
sub_issues, order_by_param = order_issue_queryset(
|
||||
sub_issues, order_by_param
|
||||
)
|
||||
|
||||
# create's a dict with state group name with their respective issue id's
|
||||
result = defaultdict(list)
|
||||
for sub_issue in sub_issues:
|
||||
@@ -148,26 +138,6 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
sub_issues = user_timezone_converter(
|
||||
sub_issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
# Grouping
|
||||
if group_by:
|
||||
result_dict = defaultdict(list)
|
||||
|
||||
for issue in sub_issues:
|
||||
if group_by == "assignees__ids":
|
||||
if issue["assignee_ids"]:
|
||||
assignee_ids = issue["assignee_ids"]
|
||||
for assignee_id in assignee_ids:
|
||||
result_dict[str(assignee_id)].append(issue)
|
||||
elif issue["assignee_ids"] == []:
|
||||
result_dict["None"].append(issue)
|
||||
|
||||
elif group_by:
|
||||
result_dict[str(issue[group_by])].append(issue)
|
||||
|
||||
return Response(
|
||||
{"sub_issues": result_dict, "state_distribution": result},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"sub_issues": sub_issues, "state_distribution": result},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -206,7 +176,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps({"parent": str(sub_issue_id)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for sub_issue_id in sub_issue_ids
|
||||
]
|
||||
|
||||
@@ -3,13 +3,7 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
Project,
|
||||
ProjectMember,
|
||||
Issue,
|
||||
)
|
||||
from plane.db.models import IssueVersion, IssueDescriptionVersion
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
IssueVersionDetailSerializer,
|
||||
@@ -72,7 +66,7 @@ class IssueVersionEndpoint(BaseAPIView):
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkItemDescriptionVersionEndpoint(BaseAPIView):
|
||||
class IssueDescriptionVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
@@ -84,34 +78,10 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, work_item_id, pk=None):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=work_item_id
|
||||
)
|
||||
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=ROLE.GUEST.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
issue_description_version = IssueDescriptionVersion.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=work_item_id,
|
||||
pk=pk,
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
|
||||
serializer = IssueDescriptionVersionDetailSerializer(
|
||||
@@ -135,8 +105,8 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
|
||||
).order_by("-created_at")
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_description_versions_queryset,
|
||||
queryset=issue_description_versions_queryset,
|
||||
|
||||
@@ -61,7 +61,7 @@ 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
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
@@ -376,7 +376,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
module = user_timezone_converter(
|
||||
@@ -710,31 +710,23 @@ class ModuleViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
module_queryset = self.get_queryset().filter(pk=pk)
|
||||
module = self.get_queryset().filter(pk=pk)
|
||||
|
||||
current_module = module_queryset.first()
|
||||
|
||||
if not current_module:
|
||||
return Response(
|
||||
{"error": "Module not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if current_module.archived_at:
|
||||
if module.first().archived_at:
|
||||
return Response(
|
||||
{"error": "Archived module cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
ModuleSerializer(current_module).data, cls=DjangoJSONEncoder
|
||||
ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
serializer = ModuleWriteSerializer(
|
||||
current_module, data=request.data, partial=True
|
||||
module.first(), data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
module = module_queryset.values(
|
||||
module = module.values(
|
||||
# Required fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
@@ -776,7 +768,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
@@ -803,7 +795,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
current_instance=json.dumps({"module_name": str(module.name)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in module_issues
|
||||
]
|
||||
|
||||
@@ -34,7 +34,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
@@ -221,7 +221,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
@@ -261,7 +261,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for module in modules
|
||||
]
|
||||
@@ -284,7 +284,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
module_issue.delete()
|
||||
|
||||
@@ -309,7 +309,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
module_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -39,7 +39,6 @@ from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
@@ -180,7 +179,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
"inbox_view",
|
||||
"guest_view_all_features",
|
||||
"project_lead",
|
||||
"network",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
@@ -275,14 +273,14 @@ class ProjectViewSet(BaseViewSet):
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"color": "#3A3A3A",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
@@ -294,13 +292,13 @@ class ProjectViewSet(BaseViewSet):
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"color": "#16A34A",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
@@ -332,7 +330,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
@@ -342,7 +340,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -351,7 +349,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
@@ -410,7 +408,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -420,7 +418,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -429,7 +427,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
@@ -455,7 +453,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
new_value=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
current_site=base_host(request=request, is_app=True),
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
event_id=project.id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
|
||||
@@ -16,18 +16,18 @@ from rest_framework.permissions import AllowAny
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import ProjectMemberInviteSerializer
|
||||
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
from plane.db.models import (
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
IssueUserProperty,
|
||||
)
|
||||
from plane.db.models.project import ProjectNetwork
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectInvitationsViewset(BaseViewSet):
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
@@ -99,7 +99,7 @@ class ProjectInvitationsViewset(BaseViewSet):
|
||||
project_invitations = ProjectMemberInvite.objects.bulk_create(
|
||||
project_invitations, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
current_site = base_host(request=request, is_app=True)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
|
||||
# Send invitations
|
||||
for invitation in project_invitations:
|
||||
@@ -128,7 +128,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner", "project")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def create(self, request, slug):
|
||||
project_ids = request.data.get("project_ids", [])
|
||||
|
||||
@@ -137,20 +136,11 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
member=request.user, workspace__slug=slug, is_active=True
|
||||
)
|
||||
|
||||
# Get all the projects
|
||||
projects = Project.objects.filter(
|
||||
id__in=project_ids, workspace__slug=slug
|
||||
).only("id", "network")
|
||||
# Check if user has permission to join each project
|
||||
for project in projects:
|
||||
if (
|
||||
project.network == ProjectNetwork.SECRET.value
|
||||
and workspace_member.role != ROLE.ADMIN.value
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only workspace admins can join private project"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
|
||||
return Response(
|
||||
{"error": "You do not have permission to join the project"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
workspace_role = workspace_member.role
|
||||
workspace = workspace_member.workspace
|
||||
|
||||
@@ -10,7 +10,11 @@ from plane.app.serializers import (
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
from plane.app.permissions import (
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
|
||||
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
||||
@@ -22,6 +26,14 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "leave":
|
||||
self.permission_classes = [ProjectLitePermission]
|
||||
else:
|
||||
self.permission_classes = [ProjectMemberPermission]
|
||||
|
||||
return super(ProjectMemberViewSet, self).get_permissions()
|
||||
|
||||
search_fields = ["member__display_name", "member__first_name"]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -175,20 +187,12 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True
|
||||
)
|
||||
|
||||
# Fetch the workspace role of the project member
|
||||
workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=project_member.member, is_active=True
|
||||
).role
|
||||
is_workspace_admin = workspace_role == ROLE.ADMIN.value
|
||||
|
||||
# Check if the user is not editing their own role if they are not an admin
|
||||
if request.user.id == project_member.member_id and not is_workspace_admin:
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -201,6 +205,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=project_member.member, is_active=True
|
||||
).role
|
||||
if workspace_role in [5] and int(
|
||||
request.data.get("role", project_member.role)
|
||||
) in [15, 20]:
|
||||
@@ -215,7 +222,6 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role))
|
||||
> requested_project_member.role
|
||||
and not is_workspace_admin
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
from collections import defaultdict
|
||||
|
||||
# Django imports
|
||||
from django.db.utils import IntegrityError
|
||||
@@ -75,19 +74,7 @@ class StateViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
states = StateSerializer(self.get_queryset(), many=True).data
|
||||
|
||||
grouped_states = defaultdict(list)
|
||||
for state in states:
|
||||
grouped_states[state["group"]].append(state)
|
||||
|
||||
for group, group_states in grouped_states.items():
|
||||
count = len(group_states)
|
||||
|
||||
for index, state in enumerate(group_states, start=1):
|
||||
state["order"] = index / count
|
||||
|
||||
grouped = request.GET.get("grouped", False)
|
||||
|
||||
if grouped == "true":
|
||||
state_dict = {}
|
||||
for key, value in groupby(
|
||||
@@ -96,7 +83,6 @@ class StateViewSet(BaseViewSet):
|
||||
):
|
||||
state_dict[str(key)] = list(value)
|
||||
return Response(state_dict, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(states, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
|
||||
@@ -21,161 +21,221 @@ class TimezoneEndpoint(APIView):
|
||||
|
||||
throttle_classes = [AuthenticationThrottle]
|
||||
|
||||
@method_decorator(cache_page(60 * 60 * 2))
|
||||
@method_decorator(cache_page(60 * 60 * 24))
|
||||
def get(self, request):
|
||||
timezone_locations = [
|
||||
('Midway Island', 'Pacific/Midway'), # UTC-11:00
|
||||
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
|
||||
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
|
||||
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
|
||||
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
|
||||
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00)
|
||||
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00
|
||||
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Arizona', 'America/Phoenix'), # UTC-07:00
|
||||
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Saskatchewan', 'America/Regina'), # UTC-06:00
|
||||
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
|
||||
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
|
||||
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Lima', 'America/Lima'), # UTC-05:00
|
||||
('Bogota', 'America/Bogota'), # UTC-05:00
|
||||
('Quito', 'America/Guayaquil'), # UTC-05:00
|
||||
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
|
||||
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Caracas', 'America/Caracas'), # UTC-04:00
|
||||
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('La Paz', 'America/La_Paz'), # UTC-04:00
|
||||
('Manaus', 'America/Manaus'), # UTC-04:00
|
||||
('Georgetown', 'America/Guyana'), # UTC-04:00
|
||||
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
|
||||
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
|
||||
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
|
||||
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
|
||||
('Montevideo', 'America/Montevideo'), # UTC-03:00
|
||||
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
|
||||
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
|
||||
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
|
||||
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
|
||||
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
|
||||
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
|
||||
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
|
||||
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
|
||||
('Algiers', 'Africa/Algiers'), # UTC+01:00
|
||||
('Lagos', 'Africa/Lagos'), # UTC+01:00
|
||||
('Tunis', 'Africa/Tunis'), # UTC+01:00
|
||||
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
|
||||
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
|
||||
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
|
||||
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
|
||||
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
|
||||
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
|
||||
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
|
||||
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
|
||||
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
|
||||
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
|
||||
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
|
||||
('Islamabad', 'Asia/Karachi'), # UTC+05:00
|
||||
('Karachi', 'Asia/Karachi'), # UTC+05:00
|
||||
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
|
||||
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
|
||||
('Maldives', 'Indian/Maldives'), # UTC+05:00
|
||||
('Chagos', 'Indian/Chagos'), # UTC+05:00
|
||||
('Chennai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
|
||||
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
|
||||
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
|
||||
('Almaty', 'Asia/Almaty'), # UTC+06:00
|
||||
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
|
||||
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
|
||||
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
|
||||
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
|
||||
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
|
||||
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
|
||||
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
|
||||
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
|
||||
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
|
||||
('Beijing', 'Asia/Shanghai'), # UTC+08:00
|
||||
('Singapore', 'Asia/Singapore'), # UTC+08:00
|
||||
('Perth', 'Australia/Perth'), # UTC+08:00
|
||||
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00
|
||||
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00
|
||||
('Palau', 'Pacific/Palau'), # UTC+08:00
|
||||
('Eucla', 'Australia/Eucla'), # UTC+08:45
|
||||
('Tokyo', 'Asia/Tokyo'), # UTC+09:00
|
||||
('Seoul', 'Asia/Seoul'), # UTC+09:00
|
||||
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00
|
||||
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30)
|
||||
('Darwin', 'Australia/Darwin'), # UTC+09:30
|
||||
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00)
|
||||
('Brisbane', 'Australia/Brisbane'), # UTC+10:00
|
||||
('Guam', 'Pacific/Guam'), # UTC+10:00
|
||||
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00
|
||||
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00
|
||||
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00)
|
||||
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00
|
||||
('Magadan', 'Asia/Magadan'), # UTC+11:00
|
||||
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00
|
||||
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00
|
||||
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00
|
||||
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Anadyr', 'Asia/Anadyr'), # UTC+12:00
|
||||
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45)
|
||||
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00
|
||||
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00)
|
||||
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00
|
||||
]
|
||||
timezone_mapping = {
|
||||
"-1100": [
|
||||
("Midway Island", "Pacific/Midway"),
|
||||
("American Samoa", "Pacific/Pago_Pago"),
|
||||
],
|
||||
"-1000": [
|
||||
("Hawaii", "Pacific/Honolulu"),
|
||||
("Aleutian Islands", "America/Adak"),
|
||||
],
|
||||
"-0930": [("Marquesas Islands", "Pacific/Marquesas")],
|
||||
"-0900": [
|
||||
("Alaska", "America/Anchorage"),
|
||||
("Gambier Islands", "Pacific/Gambier"),
|
||||
],
|
||||
"-0800": [
|
||||
("Pacific Time (US and Canada)", "America/Los_Angeles"),
|
||||
("Baja California", "America/Tijuana"),
|
||||
],
|
||||
"-0700": [
|
||||
("Mountain Time (US and Canada)", "America/Denver"),
|
||||
("Arizona", "America/Phoenix"),
|
||||
("Chihuahua, Mazatlan", "America/Chihuahua"),
|
||||
],
|
||||
"-0600": [
|
||||
("Central Time (US and Canada)", "America/Chicago"),
|
||||
("Saskatchewan", "America/Regina"),
|
||||
("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
|
||||
("Tegucigalpa, Honduras", "America/Tegucigalpa"),
|
||||
("Costa Rica", "America/Costa_Rica"),
|
||||
],
|
||||
"-0500": [
|
||||
("Eastern Time (US and Canada)", "America/New_York"),
|
||||
("Lima", "America/Lima"),
|
||||
("Bogota", "America/Bogota"),
|
||||
("Quito", "America/Guayaquil"),
|
||||
("Chetumal", "America/Cancun"),
|
||||
],
|
||||
"-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
|
||||
"-0400": [
|
||||
("Atlantic Time (Canada)", "America/Halifax"),
|
||||
("Caracas", "America/Caracas"),
|
||||
("Santiago", "America/Santiago"),
|
||||
("La Paz", "America/La_Paz"),
|
||||
("Manaus", "America/Manaus"),
|
||||
("Georgetown", "America/Guyana"),
|
||||
("Bermuda", "Atlantic/Bermuda"),
|
||||
],
|
||||
"-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
|
||||
"-0300": [
|
||||
("Buenos Aires", "America/Argentina/Buenos_Aires"),
|
||||
("Brasilia", "America/Sao_Paulo"),
|
||||
("Greenland", "America/Godthab"),
|
||||
("Montevideo", "America/Montevideo"),
|
||||
("Falkland Islands", "Atlantic/Stanley"),
|
||||
],
|
||||
"-0200": [
|
||||
(
|
||||
"South Georgia and the South Sandwich Islands",
|
||||
"Atlantic/South_Georgia",
|
||||
)
|
||||
],
|
||||
"-0100": [
|
||||
("Azores", "Atlantic/Azores"),
|
||||
("Cape Verde Islands", "Atlantic/Cape_Verde"),
|
||||
],
|
||||
"+0000": [
|
||||
("Dublin", "Europe/Dublin"),
|
||||
("Reykjavik", "Atlantic/Reykjavik"),
|
||||
("Lisbon", "Europe/Lisbon"),
|
||||
("Monrovia", "Africa/Monrovia"),
|
||||
("Casablanca", "Africa/Casablanca"),
|
||||
],
|
||||
"+0100": [
|
||||
("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
|
||||
("West Central Africa", "Africa/Lagos"),
|
||||
("Algiers", "Africa/Algiers"),
|
||||
("Lagos", "Africa/Lagos"),
|
||||
("Tunis", "Africa/Tunis"),
|
||||
],
|
||||
"+0200": [
|
||||
("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
|
||||
("Athens", "Europe/Athens"),
|
||||
("Jerusalem", "Asia/Jerusalem"),
|
||||
("Johannesburg", "Africa/Johannesburg"),
|
||||
("Harare, Pretoria", "Africa/Harare"),
|
||||
],
|
||||
"+0300": [
|
||||
("Moscow Time", "Europe/Moscow"),
|
||||
("Baghdad", "Asia/Baghdad"),
|
||||
("Nairobi", "Africa/Nairobi"),
|
||||
("Kuwait, Riyadh", "Asia/Riyadh"),
|
||||
],
|
||||
"+0330": [("Tehran", "Asia/Tehran")],
|
||||
"+0400": [
|
||||
("Abu Dhabi", "Asia/Dubai"),
|
||||
("Baku", "Asia/Baku"),
|
||||
("Yerevan", "Asia/Yerevan"),
|
||||
("Astrakhan", "Europe/Astrakhan"),
|
||||
("Tbilisi", "Asia/Tbilisi"),
|
||||
("Mauritius", "Indian/Mauritius"),
|
||||
],
|
||||
"+0500": [
|
||||
("Islamabad", "Asia/Karachi"),
|
||||
("Karachi", "Asia/Karachi"),
|
||||
("Tashkent", "Asia/Tashkent"),
|
||||
("Yekaterinburg", "Asia/Yekaterinburg"),
|
||||
("Maldives", "Indian/Maldives"),
|
||||
("Chagos", "Indian/Chagos"),
|
||||
],
|
||||
"+0530": [
|
||||
("Chennai", "Asia/Kolkata"),
|
||||
("Kolkata", "Asia/Kolkata"),
|
||||
("Mumbai", "Asia/Kolkata"),
|
||||
("New Delhi", "Asia/Kolkata"),
|
||||
("Sri Jayawardenepura", "Asia/Colombo"),
|
||||
],
|
||||
"+0545": [("Kathmandu", "Asia/Kathmandu")],
|
||||
"+0600": [
|
||||
("Dhaka", "Asia/Dhaka"),
|
||||
("Almaty", "Asia/Almaty"),
|
||||
("Bishkek", "Asia/Bishkek"),
|
||||
("Thimphu", "Asia/Thimphu"),
|
||||
],
|
||||
"+0630": [
|
||||
("Yangon (Rangoon)", "Asia/Yangon"),
|
||||
("Cocos Islands", "Indian/Cocos"),
|
||||
],
|
||||
"+0700": [
|
||||
("Bangkok", "Asia/Bangkok"),
|
||||
("Hanoi", "Asia/Ho_Chi_Minh"),
|
||||
("Jakarta", "Asia/Jakarta"),
|
||||
("Novosibirsk", "Asia/Novosibirsk"),
|
||||
("Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||
],
|
||||
"+0800": [
|
||||
("Beijing", "Asia/Shanghai"),
|
||||
("Singapore", "Asia/Singapore"),
|
||||
("Perth", "Australia/Perth"),
|
||||
("Hong Kong", "Asia/Hong_Kong"),
|
||||
("Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||
("Palau", "Pacific/Palau"),
|
||||
],
|
||||
"+0845": [("Eucla", "Australia/Eucla")],
|
||||
"+0900": [
|
||||
("Tokyo", "Asia/Tokyo"),
|
||||
("Seoul", "Asia/Seoul"),
|
||||
("Yakutsk", "Asia/Yakutsk"),
|
||||
],
|
||||
"+0930": [
|
||||
("Adelaide", "Australia/Adelaide"),
|
||||
("Darwin", "Australia/Darwin"),
|
||||
],
|
||||
"+1000": [
|
||||
("Sydney", "Australia/Sydney"),
|
||||
("Brisbane", "Australia/Brisbane"),
|
||||
("Guam", "Pacific/Guam"),
|
||||
("Vladivostok", "Asia/Vladivostok"),
|
||||
("Tahiti", "Pacific/Tahiti"),
|
||||
],
|
||||
"+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
|
||||
"+1100": [
|
||||
("Solomon Islands", "Pacific/Guadalcanal"),
|
||||
("Magadan", "Asia/Magadan"),
|
||||
("Norfolk Island", "Pacific/Norfolk"),
|
||||
("Bougainville Island", "Pacific/Bougainville"),
|
||||
("Chokurdakh", "Asia/Srednekolymsk"),
|
||||
],
|
||||
"+1200": [
|
||||
("Auckland", "Pacific/Auckland"),
|
||||
("Wellington", "Pacific/Auckland"),
|
||||
("Fiji Islands", "Pacific/Fiji"),
|
||||
("Anadyr", "Asia/Anadyr"),
|
||||
],
|
||||
"+1245": [("Chatham Islands", "Pacific/Chatham")],
|
||||
"+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
|
||||
"+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
|
||||
}
|
||||
|
||||
timezone_list = []
|
||||
now = datetime.now()
|
||||
|
||||
# Process timezone mapping
|
||||
for friendly_name, tz_identifier in timezone_locations:
|
||||
for offset, locations in timezone_mapping.items():
|
||||
sign = "-" if offset.startswith("-") else "+"
|
||||
hours = offset[1:3]
|
||||
minutes = offset[3:] if len(offset) > 3 else "00"
|
||||
|
||||
try:
|
||||
tz = pytz.timezone(tz_identifier)
|
||||
current_offset = now.astimezone(tz).strftime("%z")
|
||||
for friendly_name, tz_identifier in locations:
|
||||
try:
|
||||
tz = pytz.timezone(tz_identifier)
|
||||
current_offset = now.astimezone(tz).strftime("%z")
|
||||
|
||||
# converting and formatting UTC offset to GMT offset
|
||||
current_utc_offset = now.astimezone(tz).utcoffset()
|
||||
total_seconds = int(current_utc_offset.total_seconds())
|
||||
hours_offset = total_seconds // 3600
|
||||
minutes_offset = abs(total_seconds % 3600) // 60
|
||||
offset = (
|
||||
f"{'+' if hours_offset >= 0 else '-'}"
|
||||
f"{abs(hours_offset):02}:{minutes_offset:02}"
|
||||
)
|
||||
# converting and formatting UTC offset to GMT offset
|
||||
current_utc_offset = now.astimezone(tz).utcoffset()
|
||||
total_seconds = int(current_utc_offset.total_seconds())
|
||||
hours_offset = total_seconds // 3600
|
||||
minutes_offset = abs(total_seconds % 3600) // 60
|
||||
gmt_offset = (
|
||||
f"GMT{'+' if hours_offset >= 0 else '-'}"
|
||||
f"{abs(hours_offset):02}:{minutes_offset:02}"
|
||||
)
|
||||
|
||||
timezone_value = {
|
||||
"offset": int(current_offset),
|
||||
"utc_offset": f"UTC{offset}",
|
||||
"gmt_offset": f"GMT{offset}",
|
||||
"value": tz_identifier,
|
||||
"label": f"{friendly_name}",
|
||||
}
|
||||
timezone_value = {
|
||||
"offset": int(current_offset),
|
||||
"utc_offset": f"UTC{sign}{hours}:{minutes}",
|
||||
"gmt_offset": gmt_offset,
|
||||
"value": tz_identifier,
|
||||
"label": f"{friendly_name}",
|
||||
}
|
||||
|
||||
timezone_list.append(timezone_value)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
continue
|
||||
timezone_list.append(timezone_value)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
continue
|
||||
|
||||
# Sort by offset and then by label
|
||||
timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
|
||||
|
||||
@@ -432,7 +432,7 @@ class IssueViewViewSet(BaseViewSet):
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IssueViewSerializer(issue_view)
|
||||
|
||||
@@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "URL already exists for the workspace"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
raise IntegrityError
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.workspace_seed_task import workspace_seed
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -120,15 +119,11 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Get total members and role
|
||||
total_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=serializer.data["id"]
|
||||
).count()
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
|
||||
workspace_seed.delay(serializer.data["id"])
|
||||
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
@@ -139,7 +134,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
@@ -172,9 +167,10 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
role = WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member=request.user, is_active=True
|
||||
).values("role")
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.prefetch_related(
|
||||
|
||||
@@ -29,7 +29,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ from plane.db.models import (
|
||||
from .. import BaseViewSet
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
model = DraftIssue
|
||||
@@ -241,7 +241,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if request.data.get("cycle_id", None):
|
||||
@@ -270,7 +270,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if request.data.get("module_ids", []):
|
||||
@@ -300,7 +300,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for module in request.data.get("module_ids", [])
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ import jwt
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party modules
|
||||
@@ -25,8 +26,7 @@ from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
|
||||
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
workspace_invitations, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
current_site = base_host(request=request, is_app=True)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
|
||||
# Send invitations
|
||||
for invitation in workspace_invitations:
|
||||
@@ -213,7 +213,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
user=user.id if user is not None else None,
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=get_client_ip(request=request),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="MEMBER_ACCEPTED",
|
||||
accepted_from="EMAIL",
|
||||
)
|
||||
|
||||
@@ -68,11 +68,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If a user is moved to a guest role he can't have any other role in projects
|
||||
if "role" in request.data and int(request.data.get("role")) == 5:
|
||||
ProjectMember.objects.filter(
|
||||
if workspace_member.role > int(request.data.get("role")):
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id
|
||||
).update(role=5)
|
||||
).update(role=int(request.data.get("role")))
|
||||
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_member, data=request.data, partial=True
|
||||
|
||||
@@ -8,7 +8,6 @@ from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import State
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.utils.cache import cache_response
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
@@ -23,16 +22,5 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
project__archived_at__isnull=True,
|
||||
is_triage=False,
|
||||
)
|
||||
|
||||
grouped_states = defaultdict(list)
|
||||
for state in states:
|
||||
grouped_states[state.group].append(state)
|
||||
|
||||
for group, group_states in grouped_states.items():
|
||||
count = len(group_states)
|
||||
|
||||
for index, state in enumerate(group_states, start=1):
|
||||
state.order = index / count
|
||||
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -15,8 +15,8 @@ from plane.db.models import Profile, User, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
|
||||
from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.authentication.utils.host import base_host
|
||||
|
||||
|
||||
class Adapter:
|
||||
"""Common interface for all auth providers"""
|
||||
@@ -108,7 +108,7 @@ class Adapter:
|
||||
user.last_login_medium = self.provider
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = get_client_ip(request=self.request)
|
||||
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
# If user is not active, send the activation email and set the user as active
|
||||
|
||||
@@ -36,12 +36,10 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"OAUTH_NOT_CONFIGURED": 5104,
|
||||
"GOOGLE_NOT_CONFIGURED": 5105,
|
||||
"GITHUB_NOT_CONFIGURED": 5110,
|
||||
"GITHUB_USER_NOT_IN_ORG": 5122,
|
||||
"GITLAB_NOT_CONFIGURED": 5111,
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
|
||||
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
|
||||
|
||||
# Reset Password
|
||||
"INVALID_PASSWORD_TOKEN": 5125,
|
||||
"EXPIRED_PASSWORD_TOKEN": 5130,
|
||||
|
||||
@@ -18,16 +18,11 @@ from plane.authentication.adapter.error import (
|
||||
class GitHubOAuthProvider(OauthAdapter):
|
||||
token_url = "https://github.com/login/oauth/access_token"
|
||||
userinfo_url = "https://api.github.com/user"
|
||||
org_membership_url = f"https://api.github.com/orgs"
|
||||
|
||||
provider = "github"
|
||||
scope = "read:user user:email"
|
||||
|
||||
organization_scope = "read:org"
|
||||
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
@@ -37,10 +32,6 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@@ -52,10 +43,6 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
client_id = GITHUB_CLIENT_ID
|
||||
client_secret = GITHUB_CLIENT_SECRET
|
||||
self.organization_id = GITHUB_ORGANIZATION_ID
|
||||
|
||||
if self.organization_id:
|
||||
self.scope += f" {self.organization_scope}"
|
||||
|
||||
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
|
||||
url_params = {
|
||||
@@ -126,26 +113,12 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
|
||||
def is_user_in_organization(self, github_username):
|
||||
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
|
||||
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
|
||||
return response.status_code == 200 # 200 means the user is a member
|
||||
|
||||
def set_user_data(self):
|
||||
user_info_response = self.get_user_response()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token_data.get('access_token')}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
if self.organization_id:
|
||||
if not self.is_user_in_organization(user_info_response.get("login")):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
|
||||
error_message="GITHUB_USER_NOT_IN_ORG",
|
||||
)
|
||||
|
||||
|
||||
email = self.__get_email(headers=headers)
|
||||
super().set_user_data(
|
||||
{
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
# Third party imports
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module imports
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str:
|
||||
def base_host(request, is_admin=False, is_space=False, is_app=False):
|
||||
"""Utility function to return host / origin from the request"""
|
||||
# Calculate the base origin from request
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
base_origin = str(
|
||||
request.META.get("HTTP_ORIGIN")
|
||||
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
|
||||
or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}"""
|
||||
)
|
||||
|
||||
# Admin redirections
|
||||
if is_admin:
|
||||
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
|
||||
if not admin_base_path.startswith("/"):
|
||||
admin_base_path = "/" + admin_base_path
|
||||
if not admin_base_path.endswith("/"):
|
||||
admin_base_path += "/"
|
||||
|
||||
if settings.ADMIN_BASE_URL:
|
||||
return settings.ADMIN_BASE_URL + admin_base_path
|
||||
return settings.ADMIN_BASE_URL
|
||||
else:
|
||||
return base_origin + admin_base_path
|
||||
return base_origin + "/god-mode/"
|
||||
|
||||
# Space redirections
|
||||
if is_space:
|
||||
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
|
||||
if not space_base_path.startswith("/"):
|
||||
space_base_path = "/" + space_base_path
|
||||
if not space_base_path.endswith("/"):
|
||||
space_base_path += "/"
|
||||
|
||||
if settings.SPACE_BASE_URL:
|
||||
return settings.SPACE_BASE_URL + space_base_path
|
||||
return settings.SPACE_BASE_URL
|
||||
else:
|
||||
return base_origin + space_base_path
|
||||
return base_origin + "/spaces/"
|
||||
|
||||
# App Redirection
|
||||
if is_app:
|
||||
@@ -48,5 +38,5 @@ def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space:
|
||||
return base_origin
|
||||
|
||||
|
||||
def user_ip(request: Request | HttpRequest) -> str:
|
||||
return get_client_ip(request=request)
|
||||
def user_ip(request):
|
||||
return str(request.META.get("REMOTE_ADDR"))
|
||||
|
||||
@@ -3,8 +3,8 @@ from django.contrib.auth import login
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.authentication.utils.host import base_host
|
||||
|
||||
|
||||
def user_login(request, user, is_app=False, is_admin=False, is_space=False):
|
||||
login(request=request, user=user)
|
||||
@@ -15,7 +15,7 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False):
|
||||
|
||||
device_info = {
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
|
||||
"ip_address": get_client_ip(request=request),
|
||||
"ip_address": request.META.get("REMOTE_ADDR", ""),
|
||||
"domain": base_host(
|
||||
request=request, is_app=is_app, is_admin=is_admin, is_space=is_space
|
||||
),
|
||||
|
||||
@@ -19,7 +19,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class SignInAuthEndpoint(View):
|
||||
def post(self, request):
|
||||
@@ -34,7 +34,7 @@ class SignInAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
# Base URL join
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
@@ -58,7 +58,7 @@ class SignInAuthEndpoint(View):
|
||||
params = exc.get_error_dict()
|
||||
# Next path
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -76,7 +76,7 @@ class SignInAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -92,7 +92,7 @@ class SignInAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -111,7 +111,7 @@ class SignInAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = str(next_path)
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
|
||||
@@ -121,7 +121,7 @@ class SignInAuthEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -141,7 +141,7 @@ class SignUpAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -161,7 +161,7 @@ class SignUpAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -179,7 +179,7 @@ class SignUpAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -197,7 +197,7 @@ class SignUpAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -216,7 +216,7 @@ class SignUpAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
@@ -225,7 +225,7 @@ class SignUpAuthEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
@@ -35,7 +35,7 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -49,7 +49,7 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -70,7 +70,7 @@ class GitHubCallbackEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -81,7 +81,7 @@ class GitHubCallbackEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -94,7 +94,7 @@ class GitHubCallbackEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
@@ -103,6 +103,6 @@ class GitHubCallbackEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
@@ -24,7 +24,7 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(validate_next_path(next_path))
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -35,7 +35,7 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -49,7 +49,7 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -81,7 +81,7 @@ class GitLabCallbackEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -94,7 +94,7 @@ class GitLabCallbackEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
@@ -103,6 +103,6 @@ class GitLabCallbackEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -18,7 +18,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
@@ -36,7 +36,7 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -51,7 +51,7 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -72,7 +72,7 @@ class GoogleCallbackEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
@@ -82,7 +82,7 @@ class GoogleCallbackEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = next_path
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -95,11 +95,11 @@ class GoogleCallbackEndpoint(View):
|
||||
# Get the redirection path
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path)
|
||||
url = urljoin(base_host, str(next_path) if next_path else path)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -26,7 +26,6 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(APIView):
|
||||
@@ -44,13 +43,14 @@ class MagicGenerateEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
origin = request.META.get("HTTP_ORIGIN", "/")
|
||||
email = request.data.get("email", "").strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
adapter = MagicCodeProvider(request=request, key=email)
|
||||
key, token = adapter.initiate()
|
||||
# If the smtp is configured send through here
|
||||
magic_link.delay(email, key, token)
|
||||
magic_link.delay(email, key, token, origin)
|
||||
return Response({"key": str(key)}, status=status.HTTP_200_OK)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
@@ -73,7 +73,7 @@ class MagicSignInEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -89,7 +89,7 @@ class MagicSignInEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -122,7 +122,7 @@ class MagicSignInEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
)
|
||||
@@ -145,7 +145,7 @@ class MagicSignUpEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -159,7 +159,7 @@ class MagicSignUpEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
@@ -177,7 +177,7 @@ class MagicSignUpEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = str(next_path)
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
@@ -187,7 +187,7 @@ class MagicSignUpEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ class ForgotPasswordEndpoint(APIView):
|
||||
if user:
|
||||
# Get the reset token for user
|
||||
uidb64, token = generate_password_token(user=user)
|
||||
current_site = base_host(request=request, is_app=True)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
# send the forgot password email
|
||||
forgot_password.delay(
|
||||
user.first_name, user.email, uidb64, token, current_site
|
||||
|
||||
@@ -44,21 +44,10 @@ class ChangePasswordEndpoint(APIView):
|
||||
def post(self, request):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
|
||||
# If the user password is not autoset then we need to check the old passwords
|
||||
if not user.is_password_autoset:
|
||||
old_password = request.data.get("old_password", False)
|
||||
if not old_password:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
||||
error_message="MISSING_PASSWORD",
|
||||
payload={"error": "Old password is missing"},
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get the new password
|
||||
old_password = request.data.get("old_password", False)
|
||||
new_password = request.data.get("new_password", False)
|
||||
|
||||
if not new_password:
|
||||
if not old_password or not new_password:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
||||
error_message="MISSING_PASSWORD",
|
||||
@@ -66,9 +55,7 @@ class ChangePasswordEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
# If the user password is not autoset then we need to check the old passwords
|
||||
if not user.is_password_autoset and not user.check_password(old_password):
|
||||
if not user.check_password(old_password):
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INCORRECT_OLD_PASSWORD"],
|
||||
error_message="INCORRECT_OLD_PASSWORD",
|
||||
|
||||
@@ -17,7 +17,6 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class SignInAuthSpaceEndpoint(View):
|
||||
@@ -33,7 +32,7 @@ class SignInAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -52,7 +51,7 @@ class SignInAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -68,7 +67,7 @@ class SignInAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -83,7 +82,7 @@ class SignInAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -100,7 +99,7 @@ class SignInAuthSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -118,7 +117,7 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -136,7 +135,7 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
# Validate the email
|
||||
@@ -152,7 +151,7 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -167,7 +166,7 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -184,6 +183,6 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
@@ -35,7 +34,7 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -67,7 +66,7 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -78,7 +77,7 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -94,6 +93,6 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
@@ -35,7 +34,7 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -67,7 +66,7 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -78,7 +77,7 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -94,6 +93,6 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
@@ -34,7 +33,7 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -47,7 +46,7 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -66,7 +65,7 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
@@ -76,7 +75,7 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = next_path
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -90,6 +89,6 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -23,7 +23,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
@@ -38,14 +38,14 @@ class MagicGenerateSpaceEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
origin = base_host(request=request, is_space=True)
|
||||
email = request.data.get("email", "").strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
adapter = MagicCodeProvider(request=request, key=email)
|
||||
key, token = adapter.initiate()
|
||||
# If the smtp is configured send through here
|
||||
magic_link.delay(email, key, token)
|
||||
magic_link.delay(email, key, token, origin)
|
||||
return Response({"key": str(key)}, status=status.HTTP_200_OK)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -67,7 +67,7 @@ class MagicSignInSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -80,7 +80,7 @@ class MagicSignInSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -121,7 +121,7 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
# Existing User
|
||||
@@ -134,7 +134,7 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -152,6 +152,6 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -90,7 +90,7 @@ class ForgotPasswordSpaceEndpoint(APIView):
|
||||
if user:
|
||||
# Get the reset token for user
|
||||
uidb64, token = generate_password_token(user=user)
|
||||
current_site = base_host(request=request, is_space=True)
|
||||
current_site = request.META.get("HTTP_ORIGIN")
|
||||
# send the forgot password email
|
||||
forgot_password.delay(
|
||||
user.first_name, user.email, uidb64, token, current_site
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.utils import timezone
|
||||
# Module imports
|
||||
from plane.authentication.utils.host import base_host, user_ip
|
||||
from plane.db.models import User
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class SignOutAuthSpaceEndpoint(View):
|
||||
@@ -22,8 +21,8 @@ class SignOutAuthSpaceEndpoint(View):
|
||||
user.save()
|
||||
# Log the user out
|
||||
logout(request)
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = f"{base_host(request=request, is_space=True)}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except Exception:
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = f"{base_host(request=request, is_space=True)}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -32,7 +32,7 @@ from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.bgtasks.webhook_task import webhook_activity
|
||||
from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.uuid import is_valid_uuid
|
||||
from plane.utils.valid_uuid import is_valid_uuid
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -307,10 +307,6 @@ def track_labels(
|
||||
|
||||
# Set of newly added labels
|
||||
for added_label in added_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=added_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -331,10 +327,6 @@ def track_labels(
|
||||
|
||||
# Set of dropped labels
|
||||
for dropped_label in dropped_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=dropped_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -381,10 +373,6 @@ def track_assignees(
|
||||
|
||||
bulk_subscribers = []
|
||||
for added_asignee in added_assignees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_asignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=added_asignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -418,10 +406,6 @@ def track_assignees(
|
||||
)
|
||||
|
||||
for dropped_assignee in dropped_assginees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_assignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=dropped_assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -482,7 +466,7 @@ def track_estimate_points(
|
||||
),
|
||||
old_value=old_estimate.value if old_estimate else None,
|
||||
new_value=new_estimate.value if new_estimate else None,
|
||||
field="estimate_" + new_estimate.estimate.type,
|
||||
field="estimate_point",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="updated the estimate point to ",
|
||||
@@ -1650,6 +1634,40 @@ def issue_activity(
|
||||
|
||||
# Save all the values to database
|
||||
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
|
||||
# Post the updates to segway for integrations and webhooks
|
||||
if len(issue_activities_created):
|
||||
for activity in issue_activities_created:
|
||||
webhook_activity.delay(
|
||||
event=(
|
||||
"issue_comment"
|
||||
if activity.field == "comment"
|
||||
else "intake_issue"
|
||||
if intake
|
||||
else "issue"
|
||||
),
|
||||
event_id=(
|
||||
activity.issue_comment_id
|
||||
if activity.field == "comment"
|
||||
else intake
|
||||
if intake
|
||||
else activity.issue_id
|
||||
),
|
||||
verb=activity.verb,
|
||||
field=(
|
||||
"description" if activity.field == "comment" else activity.field
|
||||
),
|
||||
old_value=(
|
||||
activity.old_value if activity.old_value != "" else None
|
||||
),
|
||||
new_value=(
|
||||
activity.new_value if activity.new_value != "" else None
|
||||
),
|
||||
actor_id=activity.actor_id,
|
||||
current_site=origin,
|
||||
slug=activity.workspace.slug,
|
||||
old_identifier=activity.old_identifier,
|
||||
new_identifier=activity.new_identifier,
|
||||
)
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
|
||||
@@ -16,7 +16,7 @@ from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def magic_link(email, key, token):
|
||||
def magic_link(email, key, token, current_site):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -71,89 +70,150 @@ MODEL_MAPPER = {
|
||||
}
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def get_model_data(
|
||||
event: str, event_id: Union[str, List[str]], many: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve and serialize model data based on the event type.
|
||||
|
||||
Args:
|
||||
event (str): The type of event/model to retrieve data for
|
||||
event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s)
|
||||
many (bool): Whether to retrieve multiple instances
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Serialized model data
|
||||
|
||||
Raises:
|
||||
ValueError: If serializer is not found for the event
|
||||
ObjectDoesNotExist: If model instance is not found
|
||||
"""
|
||||
def get_model_data(event, event_id, many=False):
|
||||
model = MODEL_MAPPER.get(event)
|
||||
if model is None:
|
||||
raise ValueError(f"Model not found for event: {event}")
|
||||
if many:
|
||||
queryset = model.objects.filter(pk__in=event_id)
|
||||
else:
|
||||
queryset = model.objects.get(pk=event_id)
|
||||
serializer = SERIALIZER_MAPPER.get(event)
|
||||
return serializer(queryset, many=many).data
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(requests.RequestException,),
|
||||
retry_backoff=600,
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
try:
|
||||
if many:
|
||||
queryset = model.objects.filter(pk__in=event_id)
|
||||
else:
|
||||
queryset = model.objects.get(pk=event_id)
|
||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||
|
||||
serializer = SERIALIZER_MAPPER.get(event)
|
||||
if serializer is None:
|
||||
raise ValueError(f"Serializer not found for event: {event}")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Autopilot",
|
||||
"X-Plane-Delivery": str(uuid.uuid4()),
|
||||
"X-Plane-Event": event,
|
||||
}
|
||||
|
||||
return serializer(queryset, many=many).data
|
||||
except ObjectDoesNotExist:
|
||||
raise ObjectDoesNotExist(f"No {event} found with id: {event_id}")
|
||||
# # Your secret key
|
||||
event_data = (
|
||||
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
|
||||
if event_data is not None
|
||||
else None
|
||||
)
|
||||
|
||||
action = {
|
||||
"POST": "create",
|
||||
"PATCH": "update",
|
||||
"PUT": "update",
|
||||
"DELETE": "delete",
|
||||
}.get(action, action)
|
||||
|
||||
payload = {
|
||||
"event": event,
|
||||
"action": action,
|
||||
"webhook_id": str(webhook.id),
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"data": event_data,
|
||||
}
|
||||
|
||||
# Use HMAC for generating signature
|
||||
if webhook.secret_key:
|
||||
hmac_signature = hmac.new(
|
||||
webhook.secret_key.encode("utf-8"),
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
)
|
||||
signature = hmac_signature.hexdigest()
|
||||
headers["X-Plane-Signature"] = signature
|
||||
|
||||
# Send the webhook event
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
# Log the webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=str(response.status_code),
|
||||
response_headers=str(response.headers),
|
||||
response_body=str(response.text),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
|
||||
except Webhook.DoesNotExist:
|
||||
return
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=500,
|
||||
response_headers="",
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
current_site=current_site,
|
||||
)
|
||||
return
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_webhook_deactivation_email(
|
||||
webhook_id: str, receiver_id: str, current_site: str, reason: str
|
||||
) -> None:
|
||||
"""
|
||||
Send an email notification when a webhook is deactivated.
|
||||
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
webhook = Webhook.objects.get(pk=webhook_id)
|
||||
subject = "Webhook Deactivated"
|
||||
message = f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||
|
||||
# Send the mail
|
||||
context = {
|
||||
"email": receiver.email,
|
||||
"message": message,
|
||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/webhook-deactivate.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
Args:
|
||||
webhook_id (str): ID of the deactivated webhook
|
||||
receiver_id (str): ID of the user to receive the notification
|
||||
current_site (str): Current site URL
|
||||
reason (str): Reason for webhook deactivation
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
webhook = Webhook.objects.get(pk=webhook_id)
|
||||
|
||||
# Get the webhook payload
|
||||
subject = "Webhook Deactivated"
|
||||
message = f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||
|
||||
# Send the mail
|
||||
context = {
|
||||
"email": receiver.email,
|
||||
"message": message,
|
||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/webhook-deactivate.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Set the email connection
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
@@ -163,7 +223,6 @@ def send_webhook_deactivation_email(
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
# Create the email message
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
@@ -173,10 +232,11 @@ def send_webhook_deactivation_email(
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logger.info("Email sent successfully.")
|
||||
logging.getLogger("plane").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
return
|
||||
|
||||
|
||||
@shared_task(
|
||||
@@ -187,29 +247,10 @@ def send_webhook_deactivation_email(
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_send_task(
|
||||
self,
|
||||
webhook_id: str,
|
||||
slug: str,
|
||||
event: str,
|
||||
event_data: Optional[Dict[str, Any]],
|
||||
action: str,
|
||||
current_site: str,
|
||||
activity: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Send webhook notifications to configured endpoints.
|
||||
|
||||
Args:
|
||||
webhook (str): Webhook ID
|
||||
slug (str): Workspace slug
|
||||
event (str): Event type
|
||||
event_data (Optional[Dict[str, Any]]): Event data to be sent
|
||||
action (str): HTTP method/action
|
||||
current_site (str): Current site URL
|
||||
activity (Optional[Dict[str, Any]]): Activity data
|
||||
"""
|
||||
self, webhook, slug, event, event_data, action, current_site, activity
|
||||
):
|
||||
try:
|
||||
webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug)
|
||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -256,12 +297,7 @@ def webhook_send_task(
|
||||
)
|
||||
signature = hmac_signature.hexdigest()
|
||||
headers["X-Plane-Signature"] = signature
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to send webhook: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Send the webhook event
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
@@ -278,7 +314,7 @@ def webhook_send_task(
|
||||
response_body=str(response.text),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
logger.info(f"Webhook {webhook.id} sent successfully")
|
||||
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
@@ -293,13 +329,12 @@ def webhook_send_task(
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
logger.error(f"Webhook {webhook.id} failed with error: {e}")
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email.delay(
|
||||
send_webhook_deactivation_email(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
@@ -309,50 +344,26 @@ def webhook_send_task(
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def webhook_activity(
|
||||
event: str,
|
||||
verb: str,
|
||||
field: Optional[str],
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
actor_id: str | uuid.UUID,
|
||||
slug: str,
|
||||
current_site: str,
|
||||
event_id: str | uuid.UUID,
|
||||
old_identifier: Optional[str],
|
||||
new_identifier: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Process and send webhook notifications for various activities in the system.
|
||||
|
||||
This task filters relevant webhooks based on the event type and sends notifications
|
||||
to all active webhooks for the workspace.
|
||||
|
||||
Args:
|
||||
event (str): Type of event (project, issue, module, cycle, issue_comment)
|
||||
verb (str): Action performed (created, updated, deleted)
|
||||
field (Optional[str]): Name of the field that was changed
|
||||
old_value (Any): Previous value of the field
|
||||
new_value (Any): New value of the field
|
||||
actor_id (str | uuid.UUID): ID of the user who performed the action
|
||||
slug (str): Workspace slug
|
||||
current_site (str): Current site URL
|
||||
event_id (str | uuid.UUID): ID of the event object
|
||||
old_identifier (Optional[str]): Previous identifier if any
|
||||
new_identifier (Optional[str]): New identifier if any
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
The function silently returns on ObjectDoesNotExist exceptions to handle
|
||||
race conditions where objects might have been deleted.
|
||||
"""
|
||||
event,
|
||||
verb,
|
||||
field,
|
||||
old_value,
|
||||
new_value,
|
||||
actor_id,
|
||||
slug,
|
||||
current_site,
|
||||
event_id,
|
||||
old_identifier,
|
||||
new_identifier,
|
||||
):
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
@@ -373,7 +384,7 @@ def webhook_activity(
|
||||
|
||||
for webhook in webhooks:
|
||||
webhook_send_task.delay(
|
||||
webhook_id=webhook.id,
|
||||
webhook=webhook.id,
|
||||
slug=slug,
|
||||
event=event,
|
||||
event_data=(
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
# Python imports
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
ProjectMember,
|
||||
IssueUserProperty,
|
||||
State,
|
||||
Label,
|
||||
Issue,
|
||||
IssueLabel,
|
||||
IssueSequence,
|
||||
IssueActivity,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def read_seed_file(filename):
|
||||
"""
|
||||
Read a JSON file from the seed directory.
|
||||
|
||||
Args:
|
||||
filename (str): Name of the JSON file to read
|
||||
|
||||
Returns:
|
||||
dict: Contents of the JSON file
|
||||
"""
|
||||
file_path = os.path.join(settings.SEED_DIR, "data", filename)
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
return json.load(file)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Seed file {filename} not found in {settings.SEED_DIR}/data")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Error decoding JSON from {filename}")
|
||||
return None
|
||||
|
||||
|
||||
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
"""Creates a project and associated members for a workspace.
|
||||
|
||||
Creates a new project using the workspace name and sets up all necessary
|
||||
member associations and user properties.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to create the project in
|
||||
|
||||
Returns:
|
||||
A mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
project_seeds = read_seed_file("projects.json")
|
||||
project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5]
|
||||
|
||||
# Create members
|
||||
workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values(
|
||||
"member_id", "role"
|
||||
)
|
||||
|
||||
projects_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not project_seeds:
|
||||
logger.warning(
|
||||
"Task: workspace_seed_task -> No project seeds found. Skipping project creation."
|
||||
)
|
||||
return projects_map
|
||||
|
||||
for project_seed in project_seeds:
|
||||
project_id = project_seed.pop("id")
|
||||
# Remove the name from seed data since we want to use workspace name
|
||||
project_seed.pop("name", None)
|
||||
project_seed.pop("identifier", None)
|
||||
|
||||
project = Project.objects.create(
|
||||
**project_seed,
|
||||
workspace=workspace,
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
# Create project members
|
||||
ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
project=project,
|
||||
member_id=workspace_member["member_id"],
|
||||
role=workspace_member["role"],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
)
|
||||
|
||||
# Create issue user properties
|
||||
IssueUserProperty.objects.bulk_create(
|
||||
[
|
||||
IssueUserProperty(
|
||||
project=project,
|
||||
user_id=workspace_member["member_id"],
|
||||
workspace_id=workspace.id,
|
||||
display_filters={
|
||||
"group_by": None,
|
||||
"order_by": "sort_order",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
)
|
||||
# update map
|
||||
projects_map[project_id] = project.id
|
||||
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
|
||||
|
||||
return projects_map
|
||||
|
||||
|
||||
def create_project_states(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID]
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates states for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
|
||||
Returns:
|
||||
A mapping of seed state IDs to actual state IDs
|
||||
"""
|
||||
|
||||
state_seeds = read_seed_file("states.json")
|
||||
state_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not state_seeds:
|
||||
return state_map
|
||||
|
||||
for state_seed in state_seeds:
|
||||
state_id = state_seed.pop("id")
|
||||
project_id = state_seed.pop("project_id")
|
||||
|
||||
state = State.objects.create(
|
||||
**state_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
state_map[state_id] = state.id
|
||||
logger.info(f"Task: workspace_seed_task -> State {state_id} created")
|
||||
return state_map
|
||||
|
||||
|
||||
def create_project_labels(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID]
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates labels for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
|
||||
Returns:
|
||||
A mapping of seed label IDs to actual label IDs
|
||||
"""
|
||||
label_seeds = read_seed_file("labels.json")
|
||||
label_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not label_seeds:
|
||||
return label_map
|
||||
|
||||
for label_seed in label_seeds:
|
||||
label_id = label_seed.pop("id")
|
||||
project_id = label_seed.pop("project_id")
|
||||
label = Label.objects.create(
|
||||
**label_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
label_map[label_id] = label.id
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Label {label_id} created")
|
||||
return label_map
|
||||
|
||||
|
||||
def create_project_issues(
|
||||
workspace: Workspace,
|
||||
project_map: Dict[int, uuid.UUID],
|
||||
states_map: Dict[int, uuid.UUID],
|
||||
labels_map: Dict[int, uuid.UUID],
|
||||
) -> None:
|
||||
"""Creates issues and their associated records for each project.
|
||||
|
||||
Creates issues along with their sequences, activities, and label associations.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
states_map: Mapping of seed state IDs to actual state IDs
|
||||
labels_map: Mapping of seed label IDs to actual label IDs
|
||||
"""
|
||||
issue_seeds = read_seed_file("issues.json")
|
||||
|
||||
if not issue_seeds:
|
||||
return
|
||||
|
||||
for issue_seed in issue_seeds:
|
||||
required_fields = ["id", "labels", "project_id", "state_id"]
|
||||
# get the values
|
||||
for field in required_fields:
|
||||
if field not in issue_seed:
|
||||
logger.error(
|
||||
f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed"
|
||||
)
|
||||
continue
|
||||
|
||||
# get the values
|
||||
issue_id = issue_seed.pop("id")
|
||||
labels = issue_seed.pop("labels")
|
||||
project_id = issue_seed.pop("project_id")
|
||||
state_id = issue_seed.pop("state_id")
|
||||
|
||||
issue = Issue.objects.create(
|
||||
**issue_seed,
|
||||
state_id=states_map[state_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
IssueSequence.objects.create(
|
||||
issue=issue,
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
IssueActivity.objects.create(
|
||||
issue=issue,
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
comment="created the issue",
|
||||
verb="created",
|
||||
actor_id=workspace.created_by_id,
|
||||
epoch=time.time(),
|
||||
)
|
||||
|
||||
for label_id in labels:
|
||||
IssueLabel.objects.create(
|
||||
issue=issue,
|
||||
label_id=labels_map[label_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_seed(workspace_id: uuid.UUID) -> None:
|
||||
"""Seeds a new workspace with initial project data.
|
||||
|
||||
Creates a complete workspace setup including:
|
||||
- Projects and project members
|
||||
- Project states
|
||||
- Project labels
|
||||
- Issues and their associations
|
||||
|
||||
Args:
|
||||
workspace_id: ID of the workspace to seed
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Task: workspace_seed_task -> Seeding workspace {workspace_id}")
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(id=workspace_id)
|
||||
|
||||
# Create a project with the same name as workspace
|
||||
project_map = create_project_and_member(workspace)
|
||||
|
||||
# Create project states
|
||||
state_map = create_project_states(workspace, project_map)
|
||||
|
||||
# Create project labels
|
||||
label_map = create_project_labels(workspace, project_map)
|
||||
|
||||
# create project issues
|
||||
create_project_issues(workspace, project_map, state_map, label_map)
|
||||
|
||||
logger.info(
|
||||
f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}"
|
||||
)
|
||||
raise e
|
||||
@@ -1,15 +1,7 @@
|
||||
# Python imports
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import Celery
|
||||
from pythonjsonlogger.jsonlogger import JsonFormatter
|
||||
from celery.signals import after_setup_logger, after_setup_task_logger
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Module imports
|
||||
from plane.settings.redis import redis_instance
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
@@ -55,28 +47,6 @@ app.conf.beat_schedule = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Setup logging
|
||||
@after_setup_logger.connect
|
||||
def setup_loggers(logger, *args, **kwargs):
|
||||
formatter = JsonFormatter(
|
||||
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
|
||||
)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(fmt=formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
@after_setup_task_logger.connect
|
||||
def setup_task_loggers(logger, *args, **kwargs):
|
||||
formatter = JsonFormatter(
|
||||
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
|
||||
)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(fmt=formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Max
|
||||
from django.db import connection, transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, Issue, IssueSequence
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix duplicate sequences"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument("issue_identifier", type=str, help="Issue Identifier")
|
||||
|
||||
def strict_str_to_int(self, s):
|
||||
if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()):
|
||||
raise ValueError("Invalid integer string")
|
||||
return int(s)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
workspace_slug = input("Workspace slug: ")
|
||||
|
||||
if not workspace_slug:
|
||||
raise CommandError("Workspace slug is required")
|
||||
|
||||
issue_identifier = options.get("issue_identifier", False)
|
||||
|
||||
# Validate issue_identifier
|
||||
if not issue_identifier:
|
||||
raise CommandError("Issue identifier is required")
|
||||
|
||||
# Validate issue identifier
|
||||
try:
|
||||
identifier = issue_identifier.split("-")
|
||||
|
||||
if len(identifier) != 2:
|
||||
raise ValueError("Invalid issue identifier format")
|
||||
|
||||
project_identifier = identifier[0]
|
||||
issue_sequence = self.strict_str_to_int(identifier[1])
|
||||
|
||||
# Fetch the project
|
||||
project = Project.objects.get(
|
||||
identifier__iexact=project_identifier, workspace__slug=workspace_slug
|
||||
)
|
||||
|
||||
# Get the issues
|
||||
issues = Issue.objects.filter(project=project, sequence_id=issue_sequence)
|
||||
# Check if there are duplicate issues
|
||||
if not issues.count() > 1:
|
||||
raise CommandError(
|
||||
"No duplicate issues found with the given identifier"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"{issues.count()} issues found with identifier {issue_identifier}"
|
||||
)
|
||||
)
|
||||
with transaction.atomic():
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
lock_key = convert_uuid_to_integer(project.id)
|
||||
|
||||
# Acquire an exclusive lock using the project ID as the lock key
|
||||
with connection.cursor() as cursor:
|
||||
# Get an exclusive lock using the project ID as the lock key
|
||||
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
|
||||
|
||||
# Get the maximum sequence ID for the project
|
||||
last_sequence = IssueSequence.objects.filter(project=project).aggregate(
|
||||
largest=Max("sequence")
|
||||
)["largest"]
|
||||
|
||||
bulk_issues = []
|
||||
bulk_issue_sequences = []
|
||||
|
||||
issue_sequence_map = {
|
||||
isq.issue_id: isq
|
||||
for isq in IssueSequence.objects.filter(project=project)
|
||||
}
|
||||
|
||||
# change the ids of duplicate issues
|
||||
for index, issue in enumerate(issues[1:]):
|
||||
updated_sequence_id = last_sequence + index + 1
|
||||
issue.sequence_id = updated_sequence_id
|
||||
bulk_issues.append(issue)
|
||||
|
||||
# Find the same issue sequence instance from the above queryset
|
||||
sequence_identifier = issue_sequence_map.get(issue.id)
|
||||
if sequence_identifier:
|
||||
sequence_identifier.sequence = updated_sequence_id
|
||||
bulk_issue_sequences.append(sequence_identifier)
|
||||
|
||||
Issue.objects.bulk_update(bulk_issues, ["sequence_id"])
|
||||
IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Sequence IDs updated successfully"))
|
||||
except Exception as e:
|
||||
raise CommandError(str(e))
|
||||
@@ -1,78 +0,0 @@
|
||||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from plane.db.models import Workspace
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"slug",
|
||||
type=str,
|
||||
help="The slug of the workspace to update",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Run the command without making any changes",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
slug = options["slug"]
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
# Get the workspace with the specified slug
|
||||
try:
|
||||
workspace = Workspace.all_objects.get(slug=slug)
|
||||
except Workspace.DoesNotExist:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Workspace with slug '{slug}' not found.")
|
||||
)
|
||||
return
|
||||
|
||||
# Check if the workspace is soft-deleted
|
||||
if workspace.deleted_at is None:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check if the slug already has a timestamp appended
|
||||
if "__" in workspace.slug and workspace.slug.split("__")[-1].isdigit():
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Workspace '{workspace.name}' (slug: {workspace.slug}) already has a timestamp appended."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Get the deletion timestamp
|
||||
deletion_timestamp = int(workspace.deleted_at.timestamp())
|
||||
|
||||
# Create the new slug with the deletion timestamp
|
||||
new_slug = f"{workspace.slug}__{deletion_timestamp}"
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
workspace.slug = new_slug
|
||||
workspace.save(update_fields=["slug"])
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Updated workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error updating workspace '{workspace.name}': {str(e)}"
|
||||
)
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from sentry_sdk import capture_exception
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ def create_issue_relation(apps, schema_editor):
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
|
||||
|
||||
def update_issue_priority_choice(apps, schema_editor):
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-03-04 19:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_page",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_project",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pageversion",
|
||||
name="sub_pages_data",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-04-25 09:02
|
||||
|
||||
from django.db import migrations, models
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
def set_default_source_type(apps, schema_editor):
|
||||
IntakeIssue = apps.get_model("db", "IntakeIssue")
|
||||
IntakeIssue.objects.filter(source__iexact="in-app").update(source=SourceType.IN_APP)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('db', '0093_page_moved_to_page_page_moved_to_project_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_default_source_type,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='start_of_the_week',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, 'Sunday'), (1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday')], default=0),
|
||||
),
|
||||
]
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -31,10 +31,6 @@ class Intake(ProjectBaseModel):
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class SourceType(models.TextChoices):
|
||||
IN_APP = "IN_APP"
|
||||
|
||||
|
||||
class IntakeIssue(ProjectBaseModel):
|
||||
intake = models.ForeignKey(
|
||||
"db.Intake", related_name="issue_intake", on_delete=models.CASCADE
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models, transaction, connection
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django import apps
|
||||
@@ -15,8 +15,8 @@ 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
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
@@ -209,18 +209,11 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
# Create a lock for this specific project using an advisory lock
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
lock_key = convert_uuid_to_integer(self.project.id)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Get an exclusive lock using the project ID as the lock key
|
||||
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
|
||||
|
||||
# Get the last sequence for the project
|
||||
last_sequence = IssueSequence.objects.filter(
|
||||
project=self.project
|
||||
).aggregate(largest=models.Max("sequence"))["largest"]
|
||||
last_sequence = (
|
||||
IssueSequence.objects.filter(project=self.project)
|
||||
.select_for_update()
|
||||
.aggregate(largest=models.Max("sequence"))["largest"]
|
||||
)
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
|
||||
@@ -50,8 +50,6 @@ class Page(BaseModel):
|
||||
projects = models.ManyToManyField(
|
||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||
)
|
||||
moved_to_page = models.UUIDField(null=True, blank=True)
|
||||
moved_to_project = models.UUIDField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@@ -174,7 +172,6 @@ class PageVersion(BaseModel):
|
||||
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)
|
||||
sub_pages_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Version"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Python imports
|
||||
import pytz
|
||||
from uuid import uuid4
|
||||
from enum import Enum
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@@ -18,15 +17,6 @@ from .base import BaseModel
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(0, "Secret"), (2, "Public")]
|
||||
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {
|
||||
|
||||
@@ -164,24 +164,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
|
||||
class Profile(TimeAuditModel):
|
||||
SUNDAY = 0
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 3
|
||||
THURSDAY = 4
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
|
||||
START_OF_THE_WEEK_CHOICES = (
|
||||
(SUNDAY, "Sunday"),
|
||||
(MONDAY, "Monday"),
|
||||
(TUESDAY, "Tuesday"),
|
||||
(WEDNESDAY, "Wednesday"),
|
||||
(THURSDAY, "Thursday"),
|
||||
(FRIDAY, "Friday"),
|
||||
(SATURDAY, "Saturday"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
@@ -212,9 +194,6 @@ class Profile(TimeAuditModel):
|
||||
mobile_timezone_auto_set = models.BooleanField(default=False)
|
||||
# language
|
||||
language = models.CharField(max_length=255, default="en")
|
||||
start_of_the_week = models.PositiveSmallIntegerField(
|
||||
choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Profile"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# Python imports
|
||||
from django.db.models.functions import Ln
|
||||
import pytz
|
||||
import time
|
||||
from django.utils import timezone
|
||||
from typing import Optional, Any, Tuple, Dict
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@@ -152,34 +149,6 @@ class Workspace(BaseModel):
|
||||
return self.logo
|
||||
return None
|
||||
|
||||
def delete(
|
||||
self,
|
||||
using: Optional[str] = None,
|
||||
soft: bool = True,
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
):
|
||||
"""
|
||||
Override the delete method to append epoch timestamp to the slug when soft deleting.
|
||||
|
||||
Args:
|
||||
using: The database alias to use for the deletion.
|
||||
soft: Whether to perform a soft delete (True) or hard delete (False).
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
# Call the parent class's delete method first
|
||||
result = super().delete(using=using, soft=soft, *args, **kwargs)
|
||||
|
||||
# If it's a soft delete and the model still exists (not hard deleted)
|
||||
if soft and hasattr(self, 'deleted_at') and self.deleted_at:
|
||||
# Use the deleted_at timestamp to update the slug
|
||||
deletion_timestamp: int = int(self.deleted_at.timestamp())
|
||||
self.slug = f"{self.slug}__{deletion_timestamp}"
|
||||
self.save(update_fields=["slug"])
|
||||
|
||||
return result
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace"
|
||||
verbose_name_plural = "Workspaces"
|
||||
@@ -422,7 +391,7 @@ class WorkspaceHomePreference(BaseModel):
|
||||
class WorkspaceUserPreference(BaseModel):
|
||||
"""Preference for the workspace for a user"""
|
||||
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
VIEWS = "views", "Views"
|
||||
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
|
||||
@@ -33,7 +33,6 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
class InstanceAdminEndpoint(BaseAPIView):
|
||||
@@ -218,7 +217,7 @@ class InstanceAdminSignUpEndpoint(View):
|
||||
user.is_active = True
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = get_client_ip(request=request)
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
@@ -345,7 +344,7 @@ class InstanceAdminSignInEndpoint(View):
|
||||
user.is_active = True
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = get_client_ip(request=request)
|
||||
user.last_login_ip = request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
|
||||
@@ -109,5 +109,5 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@@ -71,12 +71,6 @@ class Command(BaseCommand):
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
"category": "GITHUB",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "GITLAB_HOST",
|
||||
"value": os.environ.get("GITLAB_HOST"),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request_body = request.body
|
||||
response = self.get_response(request)
|
||||
self.process_request(request, response, request_body)
|
||||
return response
|
||||
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
# If the API key is present, log the request
|
||||
if api_key:
|
||||
try:
|
||||
APIActivityLog.objects.create(
|
||||
token_identifier=api_key,
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(request_body.decode("utf-8") if request_body else None),
|
||||
response_body=(
|
||||
response.content.decode("utf-8") if response.content else None
|
||||
),
|
||||
response_code=response.status_code,
|
||||
ip_address=request.META.get("REMOTE_ADDR", None),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
# If the token does not exist, you can decide whether to log this as an invalid attempt
|
||||
|
||||
return None
|
||||
@@ -1,111 +0,0 @@
|
||||
# Python imports
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpRequest
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module imports
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
api_logger = logging.getLogger("plane.api.request")
|
||||
|
||||
|
||||
class RequestLoggerMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def _should_log_route(self, request: Request | HttpRequest) -> bool:
|
||||
"""
|
||||
Determines whether a route should be logged based on the request and status code.
|
||||
"""
|
||||
# Don't log health checks
|
||||
if request.path == "/" and request.method == "GET":
|
||||
return False
|
||||
return True
|
||||
|
||||
def __call__(self, request):
|
||||
# get the start time
|
||||
start_time = time.time()
|
||||
|
||||
# Get the response
|
||||
response = self.get_response(request)
|
||||
|
||||
# calculate the duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Check if logging is required
|
||||
log_true = self._should_log_route(request=request)
|
||||
|
||||
# If logging is not required, return the response
|
||||
if not log_true:
|
||||
return response
|
||||
|
||||
user_id = (
|
||||
request.user.id
|
||||
if getattr(request, "user")
|
||||
and getattr(request.user, "is_authenticated", False)
|
||||
else None
|
||||
)
|
||||
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
|
||||
# Log the request information
|
||||
api_logger.info(
|
||||
f"{request.method} {request.get_full_path()} {response.status_code}",
|
||||
extra={
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": int(duration * 1000),
|
||||
"remote_addr": get_client_ip(request),
|
||||
"user_agent": user_agent,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
# return the response
|
||||
return response
|
||||
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request_body = request.body
|
||||
response = self.get_response(request)
|
||||
self.process_request(request, response, request_body)
|
||||
return response
|
||||
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
# If the API key is present, log the request
|
||||
if api_key:
|
||||
try:
|
||||
APIActivityLog.objects.create(
|
||||
token_identifier=api_key,
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(request_body.decode("utf-8") if request_body else None),
|
||||
response_body=(
|
||||
response.content.decode("utf-8") if response.content else None
|
||||
),
|
||||
response_code=response.status_code,
|
||||
ip_address=get_client_ip(request=request),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
api_logger.exception(e)
|
||||
# If the token does not exist, you can decide whether to log this as an invalid attempt
|
||||
|
||||
return None
|
||||
@@ -1,85 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Welcome to Plane 👋",
|
||||
"sequence_id": 1,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.</p><p class=\"editor-paragraph-block\">Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.</p><p class=\"editor-paragraph-block\">First thing to try</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look in the <strong>Properties</strong> section below where it says <strong>State: Todo</strong>.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on it and change it to <strong>Done</strong> from the dropdown. Alternatively, you can drag and drop the card to the Done column.</p></li></ol>",
|
||||
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
|
||||
"sort_order": 1000,
|
||||
"state_id": 3,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "1. Create Projects 🎯",
|
||||
"sequence_id": 2,
|
||||
"description_html": "<p class=\"editor-paragraph-block\"><br>A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.</p><div data-emoji-unicode=\"128204\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4cc.png\" data-logo-in-use=\"emoji\" data-background=\"light-blue\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Note: </strong>This tutorial is already set up as a Project, and these cards you're reading are work items within it!</p><p class=\"editor-paragraph-block\">We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.</p></div><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look over at the left sidebar and find where it says <strong>Projects.</strong></p><image-component src=\"https://media.docs.plane.so/seed_assets/21.png\" width=\"395px\" height=\"362px\" id=\"7cb0d276-8686-4c8e-9f00-06a18140964d\" aspectratio=\"1.0900243309002433\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Hover your mouse there and you'll see a little <strong>+</strong> icon pop up - go ahead and click it!</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">A modal opens where you can give your project a name and other details.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Notice the Access type<strong> </strong>options? <strong>Public</strong> means anyone (except Guest users) can see and join it, while <strong>Private</strong> keeps it just for those you invite.</p><div data-icon-color=\"#6d7b8a\" data-icon-name=\"Info\" data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> You can also quickly create a new project by using the keyboard shortcut <strong>P</strong> from anywhere in Plane!</p></div></li></ol>",
|
||||
"sort_order": 2000,
|
||||
"state_id": 2,
|
||||
"labels": [2],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "2. Invite your team 🤜🤛",
|
||||
"sequence_id": 3,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Let's get your teammates on board!</p><p class=\"editor-paragraph-block\">First, you'll need to invite them to your workspace before they can join specific projects:</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click on your workspace name in the top-left corner, then select <strong>Settings</strong> from the dropdown.<br></p><image-component src=\"https://media.docs.plane.so/seed_assets/31.png\" width=\"395px\" height=\"367px\" id=\"26b0f613-b9d8-48b8-a10d-1a75501f19e0\" aspectratio=\"1.074766355140187\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Head over to the <strong>Members</strong> tab - this is your user management hub. Click <strong>Add member</strong> on the top right.<br></p><image-component src=\"https://media.docs.plane.so/seed_assets/32.png\" width=\"1144.380859375px\" height=\"206.3244316692872px\" id=\"7c64e9b0-4f6d-4958-917d-f77119cd48bd\" aspectratio=\"5.546511627906977\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">To do this, go to your project's <strong>Settings</strong> page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/33.png\" width=\"1119.380859375px\" height=\"329.9601265352615px\" id=\"3029c693-19fc-458e-9f5c-fdf3511dd2b6\" aspectratio=\"3.39247311827957\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Find the <strong>Members</strong> section, select your teammate, and assign them a project role - this controls what they can do within just this project.</p></li></ol><p class=\"editor-paragraph-block\"><br>That's it!</p><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">To learn more about user management, see <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/workspaces/members\">Manage users and roles</a>.</p>",
|
||||
"description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.",
|
||||
"sort_order": 3000,
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "3. Create and assign Work Items ✏️",
|
||||
"sequence_id": 4,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.</p><p class=\"editor-paragraph-block\">Ready to add something to your project's to-do list? Here's how:</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add work item</strong> button in the top-right corner of the Work Items page.</p><image-component src=\"https://media.docs.plane.so/seed_assets/41.png\" width=\"1085.380859375px\" height=\"482.53758375605696px\" id=\"ba055bc3-4162-4750-9ad4-9434fc0e7121\" aspectratio=\"2.249318801089918\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Give your task a clear title and add any details in the description.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Set up the essentials:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Assign it to a team member (or yourself!)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose a priority level</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add start and due dates if there's a timeline</p></li></ul></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Save time by using the keyboard shortcut <strong>C</strong> from anywhere in your project to quickly create a new work item!</p></div><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to dive deeper into all the things you can do with work items? Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/overview\">documentation</a>.</p>",
|
||||
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
|
||||
"sort_order": 4000,
|
||||
"state_id": 1,
|
||||
"labels": [2],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "4. Visualize your work 🔮",
|
||||
"sequence_id": 5,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!</p><image-component src=\"https://media.docs.plane.so/seed_assets/51.png\" aspectratio=\"4.489130434782608\"></image-component><h2 class=\"editor-heading-block\">Switch between layouts</h2><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Look at the top toolbar in your project. You'll see several layout icons.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click any of these icons to instantly switch between layouts.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/issues/layouts\"><strong>Layouts</strong></a> for more info.</p></div><h2 class=\"editor-heading-block\">Filter and display options</h2><p class=\"editor-paragraph-block\">Need to focus on specific work?</p><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Filters</strong> dropdown in the toolbar. Select criteria and choose which items to show.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Display</strong> dropdown to tailor how the information appears in your layout</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Created the perfect setup? Save it for later by clicking the the <strong>Save View</strong> button.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Access saved views anytime from the <strong>Views</strong> section in your sidebar.</p></li></ol>",
|
||||
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
|
||||
"sort_order": 5000,
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "5. Use Cycles to time box tasks 🗓️",
|
||||
"sequence_id": 6,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.</p><h2 class=\"editor-heading-block\"><strong>Setup Cycles</strong></h2><ol class=\"list-decimal pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Go to the <strong>Cycles</strong> section in your project (you can find it in the left sidebar)</p><image-component src=\"https://media.docs.plane.so/seed_assets/61.png\" width=\"1144.380859375px\" height=\"341.8747850334119px\" id=\"9c3aea94-703a-4d4c-8c39-4201e994711d\" aspectratio=\"3.3473684210526318\"></image-component></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click the <strong>Add cycle </strong>button in the top-right corner</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enter details and set the start and end dates for your cycle.</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Click <strong>Create cycle</strong> and you're ready to go!</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Add existing work items to the Cycle or create new ones.</p></li></ol><div data-emoji-unicode=\"128161\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Tip:</strong> To create a new Cycle quickly, just press <code class=\"rounded bg-custom-background-80 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200\" spellcheck=\"false\">Q</code> from anywhere in your project!</p></div><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\">Want to learn more?</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Starting and stopping cycles</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Transferring work items between cycles</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Tracking progress with charts</p></li></ul><p class=\"editor-paragraph-block\">Check out our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\" href=\"https://docs.plane.so/core-concepts/cycles\">detailed documentation</a> for everything you need to know!</p>",
|
||||
"description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!",
|
||||
"sort_order": 6000,
|
||||
"state_id": 1,
|
||||
"labels": [2],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "6. Customize your settings ⚙️",
|
||||
"sequence_id": 7,
|
||||
"description_html": "<p class=\"editor-paragraph-block\">Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!</p><h2 class=\"editor-heading-block\">Workspace settings</h2><p class=\"editor-paragraph-block\">Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Invite and manage workspace members</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Upgrade plans and manage billing</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Import data from other tools</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Export your data</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Manage integrations</p></li></ul><h2 class=\"editor-heading-block\">Project Settings</h2><p class=\"editor-paragraph-block\">Each project has its own settings where you can:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Change project details and visibility</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Invite specific members to just this project</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Customize your workflow States (like adding a \"Testing\" state)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Create and organize Labels</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Enable or disable features you need (or don't need)</p></li></ul><h2 class=\"editor-heading-block\">Your Profile Settings</h2><p class=\"editor-paragraph-block\">You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:</p><ul class=\"list-disc pl-7 space-y-[--list-spacing-y] tight\" data-tight=\"true\"><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Profile settings (update your name, photo, etc.)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Choose your timezone and preferred language for the interface</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Email notification preferences (what you want to be alerted about)</p></li><li class=\"not-prose space-y-2\"><p class=\"editor-paragraph-block\">Appearance settings (light/dark mode)<br></p></li></ul><p class=\"editor-paragraph-block\">Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!</p><div data-emoji-unicode=\"128278\" data-emoji-url=\"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f516.png\" data-logo-in-use=\"emoji\" data-background=\"green\" data-block-type=\"callout-component\"><p class=\"editor-paragraph-block\"><strong>Note:</strong> Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.</p></div><p class=\"editor-paragraph-block\"></p><div class=\"py-4 border-custom-border-400\" data-type=\"horizontalRule\"><div></div></div><p class=\"editor-paragraph-block\"></p>",
|
||||
"description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.",
|
||||
"sort_order": 7000,
|
||||
"state_id": 1,
|
||||
"labels": [],
|
||||
"priority": "none",
|
||||
"project_id": 1
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user