forked from github/plane
Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11faf3f810 | ||
|
|
30ea1adf61 | ||
|
|
546aa40aa3 | ||
|
|
78669363b1 | ||
|
|
bca749986a | ||
|
|
229938114d | ||
|
|
c5e418ab47 | ||
|
|
ecdd1f1d03 | ||
|
|
e687cd6f6a | ||
|
|
9b6721790f | ||
|
|
05df65577a | ||
|
|
52d21b9dda | ||
|
|
51f10d5f36 | ||
|
|
9275e6f373 | ||
|
|
4aef8c2242 | ||
|
|
780573dadd | ||
|
|
5e625ab132 | ||
|
|
34123681cf | ||
|
|
c72ff782ac | ||
|
|
6eb72507a5 | ||
|
|
26b18b431b | ||
|
|
1bae9289f5 | ||
|
|
5c5bcb33e3 | ||
|
|
bed5f76082 | ||
|
|
124c2f772e | ||
|
|
b38898753f | ||
|
|
86a120f11e | ||
|
|
da603dc3f8 | ||
|
|
2f3970f641 | ||
|
|
d759438ebd | ||
|
|
0102f1d693 | ||
|
|
53e443d816 | ||
|
|
98b9957753 | ||
|
|
509af4662d | ||
|
|
d1f2a819f5 | ||
|
|
a42bff675b | ||
|
|
c71a2137e6 | ||
|
|
5e1f0a2604 | ||
|
|
ccab382d7d | ||
|
|
a7aa78a349 | ||
|
|
5ae963c451 | ||
|
|
07c097c9ad | ||
|
|
fc92d7d1a0 | ||
|
|
9ba8f5c21f | ||
|
|
059b8c793a | ||
|
|
93da220c4a | ||
|
|
0feab162ff | ||
|
|
55a1291b1d | ||
|
|
b12a00cf4a | ||
|
|
52ee8c5615 | ||
|
|
68108c9fe9 | ||
|
|
88fbe85087 | ||
|
|
9b423cea4b | ||
|
|
9d891ecce1 | ||
|
|
16a7bd3bda | ||
|
|
6e9f3971a5 | ||
|
|
dddfeb17b7 | ||
|
|
538d67dbd9 | ||
|
|
090870b03e | ||
|
|
0a56a30ab2 | ||
|
|
8df1648329 | ||
|
|
b69c4b6b30 | ||
|
|
e0181342c0 | ||
|
|
f9f8b5c3d9 | ||
|
|
5fadf53580 | ||
|
|
da6ecd439c | ||
|
|
7914bcf486 | ||
|
|
c9a5893c3f | ||
|
|
7361657660 | ||
|
|
60e96bcb72 | ||
|
|
3f3fb373cc | ||
|
|
a829e6fc40 | ||
|
|
864e592bc5 | ||
|
|
411a661abd | ||
|
|
61ad6b9e0e | ||
|
|
2ff49c93bd | ||
|
|
120d06983d | ||
|
|
c9cbca5ec8 | ||
|
|
275942a246 | ||
|
|
a1b09fcbc6 | ||
|
|
26f0e9da00 | ||
|
|
4a2057c0b3 | ||
|
|
82ff786666 | ||
|
|
5773ff2afd | ||
|
|
1403a536c1 | ||
|
|
eba2f3820a | ||
|
|
253edebb93 | ||
|
|
7554988164 | ||
|
|
7087b1b5f2 | ||
|
|
abdb4a4778 | ||
|
|
ad1a074292 | ||
|
|
4c2cb2368a | ||
|
|
a14f8c281b | ||
|
|
d564ea8898 | ||
|
|
7868bfa2b1 | ||
|
|
353c85120f | ||
|
|
49f37e0346 | ||
|
|
3a2f4d55d7 | ||
|
|
8443dfc9dd | ||
|
|
2f8a1dbe20 | ||
|
|
6bfeb6af34 | ||
|
|
fd3f6ab7a4 | ||
|
|
d05d814efe | ||
|
|
cc0701a823 | ||
|
|
3906503c1b | ||
|
|
c3fe221e7a | ||
|
|
e530533f9e | ||
|
|
4c3e2c252a | ||
|
|
4ede04d72f | ||
|
|
5a6fd0efdb | ||
|
|
1a72a0dff4 | ||
|
|
e4ee6a5bfb | ||
|
|
e23e9ccdbb | ||
|
|
54a536e268 | ||
|
|
110eb39a51 | ||
|
|
fc15da826b | ||
|
|
7e2caf65bb | ||
|
|
d62dc25aa0 | ||
|
|
32bf2ed56a | ||
|
|
ebf877b7fa | ||
|
|
d8eeb9e17a | ||
|
|
f2a4e026a5 | ||
|
|
8229b12c9c | ||
|
|
3c07da433a | ||
|
|
1ed7935bf0 | ||
|
|
6394f517a2 | ||
|
|
379d258375 | ||
|
|
c743d43ce2 | ||
|
|
6a27827c16 | ||
|
|
01f465fdfa | ||
|
|
1a668c19a5 | ||
|
|
206ca98307 | ||
|
|
ed0106200e | ||
|
|
473c32d3d4 | ||
|
|
0bdd26ce12 | ||
|
|
124d1c30aa | ||
|
|
df0b7d78e6 | ||
|
|
7fbf0e6358 | ||
|
|
7f1def2041 | ||
|
|
b0cdcfd91e | ||
|
|
f3be2faa8f | ||
|
|
fd7274ba1c | ||
|
|
b01afdf300 | ||
|
|
5914240290 | ||
|
|
3f22ccc528 | ||
|
|
ad3411284b | ||
|
|
ae051b28af | ||
|
|
c89592d587 | ||
|
|
c70fe91886 | ||
|
|
1a48fdd142 | ||
|
|
06ce89a03a | ||
|
|
fd30ea9a20 | ||
|
|
7acd4ef1af | ||
|
|
46d93525be | ||
|
|
56a35e74bb | ||
|
|
5ada1e5397 | ||
|
|
ddaa8df1c5 | ||
|
|
ccbe773ce1 | ||
|
|
160cc014a7 | ||
|
|
ca7d3309d3 | ||
|
|
b87e2fff4c | ||
|
|
428d0dbac9 | ||
|
|
ca799a2b02 | ||
|
|
ff87137e40 | ||
|
|
df41daf71b | ||
|
|
fef83d3153 | ||
|
|
8e094aa895 | ||
|
|
e08fc59114 | ||
|
|
0cb856b92f | ||
|
|
37303e6cb8 | ||
|
|
71b2884b57 | ||
|
|
41b7544cfc | ||
|
|
62392be5a3 | ||
|
|
c2caed8c42 | ||
|
|
048a01dbf3 | ||
|
|
8982452500 | ||
|
|
d3c56c1765 | ||
|
|
9c85704be3 | ||
|
|
f839150741 | ||
|
|
e7bb580289 | ||
|
|
d1e834eb6f | ||
|
|
33cfbbf153 | ||
|
|
bfac39f1bc | ||
|
|
537cd2f5dd | ||
|
|
352c84b026 | ||
|
|
2cef6e67d4 | ||
|
|
a0ae569a68 | ||
|
|
7d29a89eed | ||
|
|
cf8c902473 | ||
|
|
d7097330ef | ||
|
|
7a991720a8 | ||
|
|
ac6fae44e8 | ||
|
|
8496422d20 | ||
|
|
d1d8722525 | ||
|
|
f797bb20f9 | ||
|
|
464c0f2308 | ||
|
|
c9ebc20a8e | ||
|
|
0c3635cf25 | ||
|
|
f2ebac1bb4 | ||
|
|
a3f6d61347 | ||
|
|
1da86b80b2 | ||
|
|
59c0de9b57 | ||
|
|
dbbce439ff | ||
|
|
4c0857233e | ||
|
|
0dfa06e55b | ||
|
|
81f6562168 | ||
|
|
56a4e18a3c | ||
|
|
bfe581d3bd | ||
|
|
78f9028b2f | ||
|
|
885f5deebe | ||
|
|
6b1d20449b | ||
|
|
e9a0eb87cc | ||
|
|
963ccd808d | ||
|
|
1e2c1cac9c | ||
|
|
592fe94cb4 | ||
|
|
02111d779b | ||
|
|
c3aa1cb06d | ||
|
|
e73bd96dbc | ||
|
|
8c9f4b9665 | ||
|
|
be7706e62e | ||
|
|
4083f623a0 | ||
|
|
6f7b563712 | ||
|
|
4b25b7244b | ||
|
|
f774603b7f | ||
|
|
15b5db0cae | ||
|
|
66807bef0d | ||
|
|
49f19c2c44 | ||
|
|
9529ddb393 | ||
|
|
e7af3da115 | ||
|
|
d09f410f21 | ||
|
|
98e6a1366c | ||
|
|
754142afa2 | ||
|
|
42fceb4dcd | ||
|
|
e949c4e130 | ||
|
|
78c1a64690 | ||
|
|
3d5fcbd4ce | ||
|
|
f9cd1b1352 | ||
|
|
18f66805cb | ||
|
|
382a1343ea | ||
|
|
c05eb9e240 | ||
|
|
40d2990565 | ||
|
|
684df96969 | ||
|
|
1f3fdd5d0a | ||
|
|
6f2a38ad66 | ||
|
|
c127353281 | ||
|
|
705371eaf3 | ||
|
|
fae9d8cdc1 | ||
|
|
b6c0ddac50 | ||
|
|
557e96c68e | ||
|
|
7eae6b4c9e | ||
|
|
77e05a3599 | ||
|
|
2511a284eb | ||
|
|
e3da80755c | ||
|
|
58d1d8f132 | ||
|
|
50060a0bf9 | ||
|
|
bffc6a60e7 | ||
|
|
799cf230b7 | ||
|
|
37442f482b | ||
|
|
c234f2a087 | ||
|
|
857879f5ed | ||
|
|
44f8ba407d | ||
|
|
33db616767 | ||
|
|
c949c4d244 | ||
|
|
c8caa925b1 | ||
|
|
26ba4d71c3 | ||
|
|
022960d7e3 | ||
|
|
dce3c3f008 | ||
|
|
ffc6077e9b | ||
|
|
23d08a2ad1 | ||
|
|
1c98f2dca9 | ||
|
|
053ebc031d | ||
|
|
c9dee593eb | ||
|
|
bc8430220b | ||
|
|
030558c026 | ||
|
|
d8c367c51e | ||
|
|
1afb3ba4d2 | ||
|
|
8252b1ccde | ||
|
|
394f0bf555 | ||
|
|
4ce0ac6ea1 | ||
|
|
cd821a934d | ||
|
|
f80b3f1eb1 | ||
|
|
b6321438ce | ||
|
|
16604dd31b | ||
|
|
11b28048bf | ||
|
|
7e5b26ea82 | ||
|
|
5beb50fa76 | ||
|
|
af2d7d6f75 | ||
|
|
e608b58e70 | ||
|
|
a16514ed11 | ||
|
|
74329a49cc | ||
|
|
def391cb76 | ||
|
|
e526a01295 | ||
|
|
0fb4a87454 | ||
|
|
0bd6e53b44 | ||
|
|
f095594be9 | ||
|
|
2e638b28b6 | ||
|
|
aaffe37fbe |
77
.env.example
77
.env.example
@@ -1,20 +1,83 @@
|
||||
# Replace with your instance Public IP
|
||||
# Frontend
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Github ID for Github OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# Github App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_USE_SSL="0"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
OPENAI_API_KEY=""
|
||||
GPT_ENGINE=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
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
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
# Auto generated and Required that will be generated from setup.sh
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ yarn-error.log*
|
||||
|
||||
## Django ##
|
||||
venv
|
||||
.venv
|
||||
*.pyc
|
||||
staticfiles
|
||||
mediafiles
|
||||
|
||||
@@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
- pnpm version 7.22.0
|
||||
|
||||
### Setup the project
|
||||
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
@@ -13,9 +12,7 @@ RUN turbo prune --scope=app --docker
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
# First install the dependencies (as they change less often)
|
||||
@@ -44,10 +41,12 @@ FROM python:3.11.1-alpine3.17 AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV DJANGO_SETTINGS_MODULE plane.settings.production
|
||||
ENV DOCKERIZED 1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
RUN apk --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
@@ -59,8 +58,8 @@ RUN apk --update --no-cache add \
|
||||
|
||||
COPY apiserver/requirements.txt ./
|
||||
COPY apiserver/requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
@@ -81,18 +80,13 @@ COPY apiserver/plane plane/
|
||||
COPY apiserver/templates templates/
|
||||
|
||||
COPY apiserver/gunicorn.config.py ./
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY apiserver/bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -126,9 +120,6 @@ COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
798
LICENSE.txt
798
LICENSE.txt
@@ -1,201 +1,661 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
1. Definitions.
|
||||
Preamble
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
0. Definitions.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
1. Source Code.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
2. Basic Permissions.
|
||||
|
||||
Copyright 2022 Plane Software Labs Private Limited
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
88
README.md
88
README.md
@@ -15,11 +15,18 @@
|
||||
</a>
|
||||
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screen.png"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screens+Dark+Mode.png"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -38,38 +45,26 @@ The easiest way to get started with Plane is by creating a [Plane Cloud](https:/
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
- Clone the Repository
|
||||
- Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/makeplane/plane
|
||||
```
|
||||
|
||||
- Change Directory
|
||||
|
||||
```bash
|
||||
cd plane
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
- Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh localhost
|
||||
./setup.sh http://localhost
|
||||
```
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
- Export Environment Variables
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
```
|
||||
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose-hub.yml up
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
|
||||
@@ -89,41 +84,62 @@ docker-compose -f docker-compose-hub.yml up
|
||||
## 📸 Screenshots
|
||||
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Views+Dark+Mode.png"
|
||||
alt="Plane Views"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Issue+Detail+Dark+Mode.png"
|
||||
alt="Plane Issue Details"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Cycles+%26+Modules+Dark+Mode.png"
|
||||
alt="Plane Cycles and Modules"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
|
||||
alt="Plane Quick Lists"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Analytics+Dark+Mode.png"
|
||||
alt="Plane Analytics"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
|
||||
alt="Plane Command K"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Pages+Dark+Mode.png"
|
||||
alt="Plane Pages"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Commad+K+Dark+Mode.png"
|
||||
alt="Plane Command Menu"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
## 📚Documentation
|
||||
|
||||
@@ -141,4 +157,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
|
||||
|
||||
## ⛓️ Security
|
||||
|
||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.
|
||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.
|
||||
|
||||
@@ -7,7 +7,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
RUN apk --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
@@ -15,8 +15,8 @@ RUN apk --update --no-cache add \
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
@@ -46,10 +46,10 @@ COPY templates templates/
|
||||
|
||||
COPY gunicorn.config.py ./
|
||||
USER root
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
|
||||
worker: celery -A plane worker -l info
|
||||
worker: celery -A plane worker -l info
|
||||
beat: celery -A plane beat -l INFO
|
||||
5
apiserver/bin/beat
Normal file
5
apiserver/bin/beat
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
celery -A plane beat -l info
|
||||
@@ -6,4 +6,4 @@ python manage.py migrate
|
||||
# Create a Default User
|
||||
python bin/user_script.py
|
||||
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
|
||||
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission
|
||||
|
||||
@@ -89,3 +89,16 @@ class ProjectEntityPermission(BasePermission):
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
@@ -5,7 +5,6 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
|
||||
|
||||
# Permission Mappings
|
||||
Owner = 20
|
||||
Admin = 15
|
||||
@@ -44,7 +43,6 @@ class WorkSpaceBasePermission(BasePermission):
|
||||
|
||||
class WorkSpaceAdminPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -53,3 +51,13 @@ class WorkSpaceAdminPermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug
|
||||
).exists()
|
||||
|
||||
@@ -21,9 +21,9 @@ from .project import (
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .shortcut import ShortCutSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
||||
from .asset import FileAssetSerializer
|
||||
@@ -31,7 +31,6 @@ from .issue import (
|
||||
IssueCreateSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
TimeLineIssueSerializer,
|
||||
IssuePropertySerializer,
|
||||
BlockerIssueSerializer,
|
||||
BlockedIssueSerializer,
|
||||
@@ -43,6 +42,7 @@ from .issue import (
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -69,6 +69,14 @@ from .importer import ImporterSerializer
|
||||
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||
from .estimate import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
|
||||
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Django imports
|
||||
from django.db.models.functions import TruncDate
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -20,13 +23,13 @@ class CycleSerializer(BaseSerializer):
|
||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||
backlog_issues = serializers.IntegerField(read_only=True)
|
||||
assignees = serializers.SerializerMethodField(read_only=True)
|
||||
labels = serializers.SerializerMethodField(read_only=True)
|
||||
total_estimates = serializers.IntegerField(read_only=True)
|
||||
completed_estimates = serializers.IntegerField(read_only=True)
|
||||
started_estimates = serializers.IntegerField(read_only=True)
|
||||
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
|
||||
def get_assignees(self, obj):
|
||||
members = [
|
||||
{
|
||||
@@ -44,6 +47,24 @@ class CycleSerializer(BaseSerializer):
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
def get_labels(self, obj):
|
||||
labels = [
|
||||
{
|
||||
"name": label.name,
|
||||
"color": label.color,
|
||||
"id": label.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
for label in issue_cycle.issue.labels.all()
|
||||
]
|
||||
# Use a set comprehension to return only the unique objects
|
||||
unique_objects = {frozenset(item.items()) for item in labels}
|
||||
|
||||
# Convert the set back to a list of dictionaries
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
|
||||
58
apiserver/plane/api/serializers/inbox.py
Normal file
58
apiserver/plane/api/serializers/inbox.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Inbox, InboxIssue, Issue
|
||||
|
||||
|
||||
class InboxSerializer(BaseSerializer):
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
pending_issue_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Inbox
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
|
||||
class InboxIssueSerializer(BaseSerializer):
|
||||
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = InboxIssue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
|
||||
class InboxIssueLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = InboxIssue
|
||||
fields = ["id", "status", "duplicate_to", "snoozed_till", "source"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueStateInboxSerializer(BaseSerializer):
|
||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
bridge_id = serializers.UUIDField(read_only=True)
|
||||
issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
@@ -1,3 +1,6 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -13,10 +16,10 @@ from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
TimelineIssue,
|
||||
IssueProperty,
|
||||
IssueBlocker,
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
IssueLabel,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
@@ -38,6 +41,7 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"description_html",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
@@ -251,6 +255,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
instance.updated_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@@ -283,21 +288,6 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class TimeLineIssueSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = TimelineIssue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssuePropertySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueProperty
|
||||
@@ -472,12 +462,14 @@ class IssueAttachmentSerializer(BaseSerializer):
|
||||
|
||||
# Issue Serializer with state details
|
||||
class IssueStateSerializer(BaseSerializer):
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
|
||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
bridge_id = serializers.UUIDField(read_only=True)
|
||||
attachment_count = serializers.IntegerField(read_only=True)
|
||||
link_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -485,7 +477,7 @@ class IssueStateSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
parent_detail = IssueFlatSerializer(read_only=True, source="parent")
|
||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||
@@ -539,3 +531,14 @@ class IssueLiteSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueSubscriberSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueSubscriber
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
@@ -106,7 +106,7 @@ class ModuleFlatSerializer(BaseSerializer):
|
||||
|
||||
class ModuleIssueSerializer(BaseSerializer):
|
||||
module_detail = ModuleFlatSerializer(read_only=True, source="module")
|
||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||
issue_detail = ProjectLiteSerializer(read_only=True, source="issue")
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -151,7 +151,7 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ModuleSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
||||
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
|
||||
12
apiserver/plane/api/serializers/notification.py
Normal file
12
apiserver/plane/api/serializers/notification.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Notification
|
||||
|
||||
class NotificationSerializer(BaseSerializer):
|
||||
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = "__all__"
|
||||
|
||||
@@ -77,6 +77,13 @@ class ProjectSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError(detail="Project Identifier is already taken")
|
||||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ProjectDetailSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
default_assignee = UserLiteSerializer(read_only=True)
|
||||
@@ -85,6 +92,7 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@@ -93,7 +101,7 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
|
||||
class ProjectMemberSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
project = ProjectSerializer(read_only=True)
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -102,8 +110,8 @@ class ProjectMemberSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectMemberInviteSerializer(BaseSerializer):
|
||||
project = ProjectSerializer(read_only=True)
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMemberInvite
|
||||
@@ -117,7 +125,7 @@ class ProjectIdentifierSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectFavoriteSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(source="project", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectFavorite
|
||||
@@ -128,8 +136,13 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
model = ProjectMember
|
||||
fields = ["member", "id", "is_subscribed"]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
from plane.db.models import Shortcut
|
||||
|
||||
|
||||
class ShortCutSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Shortcut
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
@@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
# If the user has already filled first name or last name then he is onboarded
|
||||
def get_is_onboarded(self, obj):
|
||||
return bool(obj.first_name) or bool(obj.last_name)
|
||||
|
||||
|
||||
class UserLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -19,6 +19,7 @@ from plane.db.models import (
|
||||
class WorkSpaceSerializer(BaseSerializer):
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Workspace
|
||||
@@ -44,6 +45,8 @@ class WorkSpaceMemberSerializer(BaseSerializer):
|
||||
|
||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
# Authentication
|
||||
SignUpEndpoint,
|
||||
SignInEndpoint,
|
||||
SignOutEndpoint,
|
||||
MagicSignInEndpoint,
|
||||
@@ -21,6 +22,7 @@ from plane.api.views import (
|
||||
# User
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
## End User
|
||||
# Workspaces
|
||||
@@ -69,13 +71,14 @@ from plane.api.views import (
|
||||
BulkDeleteIssuesEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
TimeLineIssueViewSet,
|
||||
IssuePropertyViewSet,
|
||||
LabelViewSet,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
@@ -84,9 +87,6 @@ from plane.api.views import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
## End Estimates
|
||||
# Shortcuts
|
||||
ShortCutViewSet,
|
||||
## End Shortcuts
|
||||
# Views
|
||||
IssueViewViewSet,
|
||||
ViewIssuesEndpoint,
|
||||
@@ -96,12 +96,8 @@ from plane.api.views import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
## End Cycles
|
||||
# Modules
|
||||
ModuleViewSet,
|
||||
@@ -115,10 +111,6 @@ from plane.api.views import (
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
## End Pages
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
@@ -148,6 +140,10 @@ from plane.api.views import (
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
## End Inbox
|
||||
# Analytics
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
@@ -155,6 +151,10 @@ from plane.api.views import (
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
## End Analytics
|
||||
# Notification
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
## End Notification
|
||||
)
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ urlpatterns = [
|
||||
# Social Auth
|
||||
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
|
||||
# Auth
|
||||
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
|
||||
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
|
||||
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
|
||||
# Magic Sign In/Up
|
||||
@@ -178,7 +179,7 @@ urlpatterns = [
|
||||
),
|
||||
# Password Manipulation
|
||||
path(
|
||||
"password-reset/<uidb64>/<token>/",
|
||||
"reset-password/<uidb64>/<token>/",
|
||||
ResetPasswordEndpoint.as_view(),
|
||||
name="password-reset",
|
||||
),
|
||||
@@ -203,7 +204,12 @@ urlpatterns = [
|
||||
path(
|
||||
"users/me/onboard/",
|
||||
UpdateUserOnBoardedEndpoint.as_view(),
|
||||
name="change-password",
|
||||
name="user-onboard",
|
||||
),
|
||||
path(
|
||||
"users/me/tour-completed/",
|
||||
UpdateUserTourCompletedEndpoint.as_view(),
|
||||
name="user-tour",
|
||||
),
|
||||
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
|
||||
# user workspaces
|
||||
@@ -301,7 +307,6 @@ urlpatterns = [
|
||||
{
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
"get": "retrieve",
|
||||
}
|
||||
),
|
||||
name="workspace",
|
||||
@@ -474,7 +479,6 @@ urlpatterns = [
|
||||
"workspaces/<str:slug>/user-favorite-projects/",
|
||||
ProjectFavoritesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
@@ -542,30 +546,6 @@ urlpatterns = [
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
# End Estimates ##
|
||||
# Shortcuts
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
|
||||
ShortCutViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-shortcut",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/<uuid:pk>/",
|
||||
ShortCutViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-shortcut",
|
||||
),
|
||||
## End Shortcuts
|
||||
# Views
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
||||
@@ -664,21 +644,6 @@ urlpatterns = [
|
||||
CycleDateCheckEndpoint.as_view(),
|
||||
name="project-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/current-upcoming-cycles/",
|
||||
CurrentUpcomingCyclesEndpoint.as_view(),
|
||||
name="project-cycle-upcoming",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/completed-cycles/",
|
||||
CompletedCyclesEndpoint.as_view(),
|
||||
name="project-cycle-completed",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/draft-cycles/",
|
||||
DraftCyclesEndpoint.as_view(),
|
||||
name="project-cycle-draft",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/",
|
||||
CycleFavoriteViewSet.as_view(
|
||||
@@ -703,11 +668,6 @@ urlpatterns = [
|
||||
TransferCycleIssueEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
|
||||
InCompleteCyclesEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
## End Cycles
|
||||
# Issue
|
||||
path(
|
||||
@@ -848,30 +808,34 @@ urlpatterns = [
|
||||
name="project-issue-comment",
|
||||
),
|
||||
## End IssueComments
|
||||
## Roadmap
|
||||
# Issue Subscribers
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/",
|
||||
TimeLineIssueViewSet.as_view(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/",
|
||||
IssueSubscriberViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-roadmap",
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/<uuid:pk>/",
|
||||
TimeLineIssueViewSet.as_view(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/<uuid:subscriber_id>/",
|
||||
IssueSubscriberViewSet.as_view({"delete": "destroy"}),
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/subscribe/",
|
||||
IssueSubscriberViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
"get": "subscription_status",
|
||||
"post": "subscribe",
|
||||
"delete": "unsubscribe",
|
||||
}
|
||||
),
|
||||
name="project-issue-roadmap",
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
## End Roadmap
|
||||
## End Issue Subscribers
|
||||
## IssueProperty
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
|
||||
@@ -896,6 +860,36 @@ urlpatterns = [
|
||||
name="project-issue-roadmap",
|
||||
),
|
||||
## IssueProperty Ebd
|
||||
## Issue Archives
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"post": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
## End Issue Archives
|
||||
## File Assets
|
||||
path(
|
||||
"workspaces/<str:slug>/file-assets/",
|
||||
@@ -1077,26 +1071,6 @@ urlpatterns = [
|
||||
CreateIssueFromPageBlockEndpoint.as_view(),
|
||||
name="page-block-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
|
||||
RecentPagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
|
||||
FavoritePagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
|
||||
MyPagesEndpoint.as_view(),
|
||||
name="user-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
|
||||
CreatedbyOtherPagesEndpoint.as_view(),
|
||||
name="created-by-other-pages",
|
||||
),
|
||||
## End Pages
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
@@ -1290,6 +1264,50 @@ urlpatterns = [
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||
InboxViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="inbox",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:pk>/",
|
||||
InboxViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="inbox",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
|
||||
InboxIssueViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
|
||||
InboxIssueViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
## End Inbox
|
||||
# Analytics
|
||||
path(
|
||||
"workspaces/<str:slug>/analytics/",
|
||||
@@ -1324,4 +1342,51 @@ urlpatterns = [
|
||||
name="default-analytics",
|
||||
),
|
||||
## End Analytics
|
||||
# Notification
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/read/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"post": "mark_read",
|
||||
"delete": "mark_unread",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/archive/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"post": "archive",
|
||||
"delete": "unarchive",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/unread/",
|
||||
UnreadNotificationEndpoint.as_view(),
|
||||
name="unread-notifications",
|
||||
),
|
||||
## End Notification
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .project import (
|
||||
from .people import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
)
|
||||
|
||||
@@ -43,18 +44,13 @@ from .workspace import (
|
||||
WorkspaceThemeViewSet,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .shortcut import ShortCutViewSet
|
||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
from .cycle import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint
|
||||
from .issue import (
|
||||
@@ -62,7 +58,6 @@ from .issue import (
|
||||
WorkSpaceIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueCommentViewSet,
|
||||
TimeLineIssueViewSet,
|
||||
IssuePropertyViewSet,
|
||||
LabelViewSet,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
@@ -71,6 +66,8 @@ from .issue import (
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -83,6 +80,7 @@ from .auth_extended import (
|
||||
|
||||
|
||||
from .authentication import (
|
||||
SignUpEndpoint,
|
||||
SignInEndpoint,
|
||||
SignOutEndpoint,
|
||||
MagicSignInEndpoint,
|
||||
@@ -122,10 +120,6 @@ from .page import (
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
@@ -141,6 +135,8 @@ from .estimate import (
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
@@ -148,3 +144,5 @@ from .analytic import (
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
@@ -3,6 +3,7 @@ from django.db.models import (
|
||||
Count,
|
||||
Sum,
|
||||
F,
|
||||
Q
|
||||
)
|
||||
from django.db.models.functions import ExtractMonth
|
||||
|
||||
@@ -40,7 +41,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
segment = request.GET.get("segment", False)
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
distribution = build_graph_plot(
|
||||
@@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
colors = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values(key, "color")
|
||||
if filters.get("project__in", False)
|
||||
else State.objects.filter(workspace__slug=slug).values(key, "color")
|
||||
else State.objects.filter(~Q(name="Triage"), workspace__slug=slug).values(key, "color")
|
||||
)
|
||||
|
||||
if x_axis in ["labels__name"] or segment in ["labels__name"]:
|
||||
@@ -79,7 +81,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
@@ -132,7 +134,7 @@ class SavedAnalyticEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
filter = analytic_view.query
|
||||
queryset = Issue.objects.filter(**filter)
|
||||
queryset = Issue.issue_objects.filter(**filter)
|
||||
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
@@ -209,7 +211,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
try:
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from django.conf import settings
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import FileAsset
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.api.serializers import FileAssetSerializer
|
||||
|
||||
|
||||
@@ -27,15 +27,13 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
try:
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if request.user.last_workspace_id is None:
|
||||
return Response(
|
||||
{"error": "Workspace id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer.save(workspace_id=request.user.last_workspace_id)
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response({"error": "Workspace does not exist"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
|
||||
@@ -36,6 +36,99 @@ def get_tokens_for_user(user):
|
||||
)
|
||||
|
||||
|
||||
class SignUpEndpoint(BaseAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
if not settings.ENABLE_SIGNUP:
|
||||
return Response(
|
||||
{
|
||||
"error": "New account creation is disabled. Please contact your site administrator"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
email = request.data.get("email", False)
|
||||
password = request.data.get("password", False)
|
||||
|
||||
## Raise exception if any of the above are missing
|
||||
if not email or not password:
|
||||
return Response(
|
||||
{"error": "Both email and password are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError as e:
|
||||
return Response(
|
||||
{"error": "Please provide a valid email address."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the user already exists
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response(
|
||||
{"error": "User with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = User.objects.create(email=email, username=uuid.uuid4().hex)
|
||||
user.set_password(password)
|
||||
|
||||
# settings last actives for the user
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
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()
|
||||
|
||||
serialized_user = UserSerializer(user).data
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_UP",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class SignInEndpoint(BaseAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@@ -63,108 +156,69 @@ class SignInEndpoint(BaseAPIView):
|
||||
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# Sign up Process
|
||||
if user is None:
|
||||
user = User.objects.create(email=email, username=uuid.uuid4().hex)
|
||||
user.set_password(password)
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# settings last actives for the user
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
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()
|
||||
# Sign up Process
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if not user.is_active:
|
||||
return Response(
|
||||
{
|
||||
"error": "Your account has been deactivated. Please contact your site administrator."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serialized_user = UserSerializer(user).data
|
||||
serialized_user = UserSerializer(user).data
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
# settings last active for the user
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
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()
|
||||
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_UP",
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
)
|
||||
"event_type": "SIGN_IN",
|
||||
},
|
||||
)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
# Sign in Process
|
||||
else:
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if not user.is_active:
|
||||
return Response(
|
||||
{
|
||||
"error": "Your account has been deactivated. Please contact your site administrator."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serialized_user = UserSerializer(user).data
|
||||
|
||||
# settings last active for the user
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
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()
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_IN",
|
||||
},
|
||||
)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -291,7 +345,7 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user_token = request.data.get("token", "").strip().lower()
|
||||
user_token = request.data.get("token", "").strip()
|
||||
key = request.data.get("key", False)
|
||||
|
||||
if not key or user_token == "":
|
||||
|
||||
@@ -41,10 +41,12 @@ from plane.db.models import (
|
||||
CycleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@@ -148,10 +150,149 @@ class CycleViewSet(BaseViewSet):
|
||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__labels",
|
||||
queryset=Label.objects.only("name", "color", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
||||
# All Cycles
|
||||
if cycle_view == "all":
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
|
||||
data = CycleSerializer(queryset, many=True).data
|
||||
|
||||
if len(data):
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
data[0]["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
if data[0]["start_date"] and data[0]["end_date"]:
|
||||
data[0]["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=data[0]["id"],
|
||||
)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
# Upcoming Cycles
|
||||
if cycle_view == "upcoming":
|
||||
queryset = queryset.filter(start_date__gt=timezone.now())
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Completed Cycles
|
||||
if cycle_view == "completed":
|
||||
queryset = queryset.filter(end_date__lt=timezone.now())
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Draft Cycles
|
||||
if cycle_view == "draft":
|
||||
queryset = queryset.filter(
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||
)
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
if (
|
||||
@@ -213,6 +354,92 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
try:
|
||||
queryset = self.get_queryset().get(pk=pk)
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
# Label Distribution
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = CycleSerializer(queryset).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if queryset.start_date and queryset.end_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Cycle.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Cycle Does not exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
@@ -254,7 +481,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -278,9 +505,9 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -478,356 +705,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
upcoming_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"current_cycle": CycleSerializer(current_cycle, many=True).data,
|
||||
"upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CompletedCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
completed_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date__lt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"completed_cycles": CycleSerializer(
|
||||
completed_cycles, many=True
|
||||
).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DraftCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
draft_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"draft_cycles": CycleSerializer(draft_cycles, many=True).data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = CycleFavoriteSerializer
|
||||
model = CycleFavorite
|
||||
@@ -948,22 +826,3 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InCompleteCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).select_related("owned_by")
|
||||
|
||||
serializer = CycleSerializer(cycles, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -30,31 +30,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
count = 0
|
||||
|
||||
# If logger is enabled check for request limit
|
||||
if settings.LOGGER_BASE_URL:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
settings.LOGGER_BASE_URL,
|
||||
json={"user_id": str(request.user.id)},
|
||||
headers=headers,
|
||||
)
|
||||
count = response.json().get("count", 0)
|
||||
if not response.json().get("success", False):
|
||||
return Response(
|
||||
{
|
||||
"error": "You have surpassed the monthly limit for AI assistance"
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
@@ -67,7 +42,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
|
||||
openai.api_key = settings.OPENAI_API_KEY
|
||||
response = openai.Completion.create(
|
||||
engine=settings.GPT_ENGINE,
|
||||
model=settings.GPT_ENGINE,
|
||||
prompt=final_text,
|
||||
temperature=0.7,
|
||||
max_tokens=1024,
|
||||
@@ -82,7 +57,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"count": count,
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
from django.db.models import Max, Q
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseAPIView
|
||||
@@ -42,16 +42,34 @@ from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug, service):
|
||||
try:
|
||||
if service == "github":
|
||||
owner = request.GET.get("owner", False)
|
||||
repo = request.GET.get("repo", False)
|
||||
|
||||
if not owner or not repo:
|
||||
return Response(
|
||||
{"error": "Owner and repo are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
integration__provider="github", workspace__slug=slug
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
owner = request.GET.get("owner")
|
||||
repo = request.GET.get("repo")
|
||||
access_tokens_url = workspace_integration.metadata.get(
|
||||
"access_tokens_url", False
|
||||
)
|
||||
|
||||
if not access_tokens_url:
|
||||
return Response(
|
||||
{
|
||||
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue_count, labels, collaborators = get_github_repo_details(
|
||||
access_tokens_url, owner, repo
|
||||
@@ -239,17 +257,19 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
importer = Importer.objects.get(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
# Delete all imported Issues
|
||||
imported_issues = importer.imported_data.get("issues", [])
|
||||
Issue.objects.filter(id__in=imported_issues).delete()
|
||||
|
||||
# Delete all imported Labels
|
||||
imported_labels = importer.imported_data.get("labels", [])
|
||||
Label.objects.filter(id__in=imported_labels).delete()
|
||||
if importer.imported_data is not None:
|
||||
# Delete all imported Issues
|
||||
imported_issues = importer.imported_data.get("issues", [])
|
||||
Issue.issue_objects.filter(id__in=imported_issues).delete()
|
||||
|
||||
if importer.service == "jira":
|
||||
imported_modules = importer.imported_data.get("modules", [])
|
||||
Module.objects.filter(id__in=imported_modules).delete()
|
||||
# Delete all imported Labels
|
||||
imported_labels = importer.imported_data.get("labels", [])
|
||||
Label.objects.filter(id__in=imported_labels).delete()
|
||||
|
||||
if importer.service == "jira":
|
||||
imported_modules = importer.imported_data.get("modules", [])
|
||||
Module.objects.filter(id__in=imported_modules).delete()
|
||||
importer.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
@@ -307,11 +327,13 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
|
||||
# Get the default state
|
||||
default_state = State.objects.filter(
|
||||
project_id=project_id, default=True
|
||||
~Q(name="Triage"), project_id=project_id, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
default_state = State.objects.filter(project_id=project_id).first()
|
||||
default_state = State.objects.filter(
|
||||
~Q(name="Triage"), sproject_id=project_id
|
||||
).first()
|
||||
|
||||
# Get the maximum sequence_id
|
||||
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
|
||||
|
||||
380
apiserver/plane/api/views/inbox.py
Normal file
380
apiserver/plane/api/views/inbox.py
Normal file
@@ -0,0 +1,380 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueStateInboxSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class InboxViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxSerializer
|
||||
model = Inbox
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.annotate(
|
||||
pending_issue_count=Count(
|
||||
"issue_inbox",
|
||||
filter=Q(issue_inbox__status=-2),
|
||||
)
|
||||
)
|
||||
.select_related("workspace", "project")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
try:
|
||||
inbox = Inbox.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
# Handle default inbox delete
|
||||
if inbox.is_default:
|
||||
return Response(
|
||||
{"error": "You cannot delete the default inbox"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
inbox.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wronf please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InboxIssueViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxIssueSerializer
|
||||
model = InboxIssue
|
||||
|
||||
filterset_fields = [
|
||||
"status",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
inbox_id=self.kwargs.get("inbox_id"),
|
||||
)
|
||||
.select_related("issue", "workspace", "project")
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
issue_inbox__inbox_id=inbox_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(bridge_id=F("issue_inbox__id"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
queryset=InboxIssue.objects.only(
|
||||
"status", "duplicate_to", "snoozed_till", "source"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
issues_data = IssueStateInboxSerializer(issues, many=True).data
|
||||
return Response(
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create or get state
|
||||
state, _ = State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=project_id,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_id,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
inbox_id=inbox_id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# Only allow guests and viewers to edit name and description
|
||||
if project_member.role <= 10:
|
||||
# viewers and guests since only viewers and guests
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description": issue_data.get("description", issue.description)
|
||||
}
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True
|
||||
)
|
||||
|
||||
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:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
return Response(
|
||||
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Only project admins and members can edit inbox issue attributes
|
||||
if project_member.role > 10:
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
state = State.objects.filter(
|
||||
group="cancelled", workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.name == "Triage":
|
||||
# Move to default state
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, default=True
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK)
|
||||
except InboxIssue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Inbox Issue does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
|
||||
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except InboxIssue.DoesNotExist:
|
||||
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -4,11 +4,25 @@ import random
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.conf import settings
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -22,7 +36,6 @@ from plane.api.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
TimeLineIssueSerializer,
|
||||
IssuePropertySerializer,
|
||||
LabelSerializer,
|
||||
IssueSerializer,
|
||||
@@ -31,23 +44,27 @@ from plane.api.serializers import (
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
TimelineIssue,
|
||||
IssueProperty,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
IssueSubscriber,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@@ -120,10 +137,8 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -144,12 +159,17 @@ class IssueViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.order_by(request.GET.get("order_by", "created_at"))
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__id"))
|
||||
.annotate(module_id=F("issue_module__id"))
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -166,6 +186,64 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
@@ -184,7 +262,7 @@ class IssueViewSet(BaseViewSet):
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -219,7 +297,7 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
issue = Issue.issue_objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
@@ -234,9 +312,11 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = (
|
||||
Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug)
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user], workspace__slug=slug
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -282,7 +362,7 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = (
|
||||
Issue.objects.filter(workspace__slug=slug)
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
@@ -416,39 +496,6 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
class TimeLineIssueViewSet(BaseViewSet):
|
||||
serializer_class = TimeLineIssueSerializer
|
||||
model = TimelineIssue
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
"issue__id",
|
||||
"workspace__id",
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("issue")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class IssuePropertyViewSet(BaseViewSet):
|
||||
serializer_class = IssuePropertySerializer
|
||||
model = IssueProperty
|
||||
@@ -552,7 +599,7 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
|
||||
@@ -581,7 +628,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
sub_issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
parent_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.select_related("project")
|
||||
@@ -590,10 +637,32 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
State.objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
@@ -627,7 +696,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
# Assign multiple sub issues
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
parent_issue = Issue.objects.get(pk=issue_id)
|
||||
parent_issue = Issue.issue_objects.get(pk=issue_id)
|
||||
sub_issue_ids = request.data.get("sub_issue_ids", [])
|
||||
|
||||
if not len(sub_issue_ids):
|
||||
@@ -636,14 +705,14 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
for sub_issue in sub_issues:
|
||||
sub_issue.parent = parent_issue
|
||||
|
||||
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
|
||||
|
||||
updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
return Response(
|
||||
IssueFlatSerializer(updated_sub_issues, many=True).data,
|
||||
@@ -842,3 +911,347 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unarchive(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": None}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong, please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
class IssueSubscriberViewSet(BaseViewSet):
|
||||
serializer_class = IssueSubscriberSerializer
|
||||
model = IssueSubscriber
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ["subscribe", "unsubscribe", "subscription_status"]:
|
||||
self.permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
return super(IssueSubscriberViewSet, self).get_permissions()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber=OuterRef("member"),
|
||||
)
|
||||
)
|
||||
).select_related("member")
|
||||
serializer = ProjectMemberLiteSerializer(members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": e},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id, subscriber_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.get(
|
||||
project=project_id,
|
||||
subscriber=subscriber_id,
|
||||
workspace__slug=slug,
|
||||
issue=issue_id,
|
||||
)
|
||||
issue_subscriber.delete()
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
except IssueSubscriber.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User is not subscribed to this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def subscribe(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
if IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
project=project_id,
|
||||
).exists():
|
||||
return Response(
|
||||
{"message": "User already subscribed to the issue."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
subscriber = IssueSubscriber.objects.create(
|
||||
issue_id=issue_id,
|
||||
subscriber_id=request.user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
serilaizer = IssueSubscriberSerializer(subscriber)
|
||||
return Response(serilaizer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong, please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unsubscribe(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.get(
|
||||
project=project_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
issue=issue_id,
|
||||
)
|
||||
issue_subscriber.delete()
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
except IssueSubscriber.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User subscribed to this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def subscription_status(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.filter(
|
||||
issue=issue_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
project=project_id,
|
||||
).exists()
|
||||
return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong, please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ from plane.db.models import (
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
@@ -160,6 +160,87 @@ class ModuleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
try:
|
||||
queryset = self.get_queryset().get(pk=pk)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleSerializer(queryset).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if queryset.start_date and queryset.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset, slug=slug, project_id=project_id, module_id=pk
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
@@ -201,7 +282,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -226,9 +307,9 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_module__module_id=module_id)
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -399,9 +480,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
258
apiserver/plane/api/views/notification.py
Normal file
258
apiserver/plane/api/views/notification.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
|
||||
from plane.api.serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet):
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
receiver_id=self.request.user.id,
|
||||
)
|
||||
.select_related("workspace", "project," "triggered_by", "receiver")
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
snoozed = request.GET.get("snoozed", "false")
|
||||
archived = request.GET.get("archived", "false")
|
||||
read = request.GET.get("read", "true")
|
||||
|
||||
# Filter type
|
||||
type = request.GET.get("type", "all")
|
||||
|
||||
notifications = Notification.objects.filter(
|
||||
workspace__slug=slug, receiver_id=request.user.id
|
||||
).order_by("snoozed_till", "-created_at")
|
||||
|
||||
# Filter for snoozed notifications
|
||||
if snoozed == "false":
|
||||
notifications = notifications.filter(
|
||||
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
|
||||
)
|
||||
|
||||
if snoozed == "true":
|
||||
notifications = notifications.filter(
|
||||
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
|
||||
)
|
||||
|
||||
if read == "false":
|
||||
notifications = notifications.filter(read_at__isnull=True)
|
||||
|
||||
# Filter for archived or unarchive
|
||||
if archived == "false":
|
||||
notifications = notifications.filter(archived_at__isnull=True)
|
||||
|
||||
if archived == "true":
|
||||
notifications = notifications.filter(archived_at__isnull=False)
|
||||
|
||||
# Subscribed issues
|
||||
if type == "watching":
|
||||
issue_ids = IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
).values_list("issue_id", flat=True)
|
||||
notifications = notifications.filter(entity_identifier__in=issue_ids)
|
||||
|
||||
# Assigned Issues
|
||||
if type == "assigned":
|
||||
issue_ids = IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee_id=request.user.id
|
||||
).values_list("issue_id", flat=True)
|
||||
notifications = notifications.filter(entity_identifier__in=issue_ids)
|
||||
|
||||
# Created issues
|
||||
if type == "created":
|
||||
issue_ids = Issue.objects.filter(
|
||||
workspace__slug=slug, created_by=request.user
|
||||
).values_list("pk", flat=True)
|
||||
notifications = notifications.filter(entity_identifier__in=issue_ids)
|
||||
|
||||
serializer = NotificationSerializer(notifications, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
workspace__slug=slug, pk=pk, receiver=request.user
|
||||
)
|
||||
# Only read_at and snoozed_till can be updated
|
||||
notification_data = {
|
||||
"snoozed_till": request.data.get("snoozed_till", None),
|
||||
}
|
||||
serializer = NotificationSerializer(
|
||||
notification, data=notification_data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def mark_read(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.read_at = timezone.now()
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def mark_unread(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.read_at = None
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def archive(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.archived_at = timezone.now()
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unarchive(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.archived_at = None
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UnreadNotificationEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
# Watching Issues Count
|
||||
watching_issues_count = Notification.objects.filter(
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
).count()
|
||||
|
||||
# My Issues Count
|
||||
my_issues_count = Notification.objects.filter(
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
).count()
|
||||
|
||||
# Created Issues Count
|
||||
created_issues_count = Notification.objects.filter(
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=Issue.objects.filter(
|
||||
workspace__slug=slug, created_by=request.user
|
||||
).values_list("pk", flat=True),
|
||||
).count()
|
||||
|
||||
return Response(
|
||||
{
|
||||
"watching_issues": watching_issues_count,
|
||||
"my_issues": my_issues_count,
|
||||
"created_issues": created_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -125,7 +125,57 @@ class PageViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
page_view = request.GET.get("page_view", False)
|
||||
|
||||
if not page_view:
|
||||
return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# All Pages
|
||||
if page_view == "all":
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# Recent pages
|
||||
if page_view == "recent":
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
todays_pages = queryset.filter(updated_at__date=date.today())
|
||||
yesterdays_pages = queryset.filter(updated_at__date=day_before)
|
||||
earlier_this_week = queryset.filter( updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
))
|
||||
return Response(
|
||||
{
|
||||
"today": PageSerializer(todays_pages, many=True).data,
|
||||
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
|
||||
"earlier_this_week": PageSerializer(earlier_this_week, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Favorite Pages
|
||||
if page_view == "favorite":
|
||||
queryset = queryset.filter(is_favorite=True)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# My pages
|
||||
if page_view == "created_by_me":
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# Created by other Pages
|
||||
if page_view == "created_by_other":
|
||||
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response({"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class PageBlockViewSet(BaseViewSet):
|
||||
serializer_class = PageBlockSerializer
|
||||
@@ -269,249 +319,3 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class RecentPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
|
||||
todays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=date.today(),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
yesterdays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=day_before,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
earlier_this_week = (
|
||||
Page.objects.filter(
|
||||
updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
todays_pages_serializer = PageSerializer(todays_pages, many=True)
|
||||
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
|
||||
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
|
||||
return Response(
|
||||
{
|
||||
"today": todays_pages_serializer.data,
|
||||
"yesterday": yesterday_pages_serializer.data,
|
||||
"earlier_this_week": earlier_this_week_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class FavoritePagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.filter(is_favorite=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class MyPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, owned_by=request.user
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CreatedbyOtherPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
~Q(owned_by=request.user),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
access=0,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -31,36 +31,71 @@ class UserEndpoint(BaseViewSet):
|
||||
|
||||
def retrieve(self, request):
|
||||
try:
|
||||
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
|
||||
workspace = Workspace.objects.get(
|
||||
pk=request.user.last_workspace_id, workspace_member__member=request.user
|
||||
)
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
serialized_data["workspace"] = {
|
||||
"last_workspace_id": request.user.last_workspace_id,
|
||||
"last_workspace_slug": workspace.slug,
|
||||
"fallback_workspace_id": request.user.last_workspace_id,
|
||||
"fallback_workspace_slug": workspace.slug,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})[
|
||||
"assigned_issues"
|
||||
] = assigned_issues
|
||||
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": workspace.slug,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
serialized_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
# This exception will be hit even when the `last_workspace_id` is None
|
||||
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
fallback_workspace = (
|
||||
Workspace.objects.filter(workspace_member__member=request.user)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
|
||||
serialized_data["workspace"] = {
|
||||
"last_workspace_id": None,
|
||||
"last_workspace_slug": None,
|
||||
"fallback_workspace_id": fallback_workspace.id
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"fallback_workspace_slug": fallback_workspace.slug
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})[
|
||||
"assigned_issues"
|
||||
] = assigned_issues
|
||||
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": None,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
serialized_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -73,20 +108,23 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user.is_onboarded = request.data.get("is_onboarded", False)
|
||||
user.save()
|
||||
return Response(
|
||||
{"message": "Updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if user.last_workspace_id is not None:
|
||||
user_role = WorkspaceMember.objects.filter(
|
||||
workspace_id=user.last_workspace_id, member=request.user.id
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"message": "Updated successfully",
|
||||
"role": user_role.company_role
|
||||
if user_role is not None
|
||||
else None,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
class UpdateUserTourCompletedEndpoint(BaseAPIView):
|
||||
def patch(self, request):
|
||||
try:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user.is_tour_completed = request.data.get("is_tour_completed", False)
|
||||
user.save()
|
||||
return Response(
|
||||
{"message": "Updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@@ -37,18 +37,19 @@ from plane.db.models import (
|
||||
State,
|
||||
TeamMember,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
Module,
|
||||
Cycle,
|
||||
CycleFavorite,
|
||||
ModuleFavorite,
|
||||
PageFavorite,
|
||||
IssueViewFavorite,
|
||||
Page,
|
||||
IssueAssignee,
|
||||
ModuleMember,
|
||||
Inbox,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
ProjectIdentifier,
|
||||
Cycle,
|
||||
Module,
|
||||
)
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
|
||||
@@ -71,6 +72,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -80,11 +82,21 @@ class ProjectViewSet(BaseViewSet):
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(
|
||||
is_member=Exists(
|
||||
ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
is_favorite = request.GET.get("is_favorite", "all")
|
||||
subquery = ProjectFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
@@ -115,6 +127,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
if is_favorite == "true":
|
||||
projects = projects.filter(is_favorite=True)
|
||||
if is_favorite == "false":
|
||||
projects = projects.filter(is_favorite=False)
|
||||
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -133,41 +151,41 @@ class ProjectViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
## Add the user as Administrator to the project
|
||||
# Add the user as Administrator to the project
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
)
|
||||
|
||||
## Default states
|
||||
# Default states
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#5e6ad2",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#eb5757",
|
||||
"color": "#3A3A3A",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#26b5ce",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#f2c94c",
|
||||
"color": "#16A34A",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#4cb782",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
@@ -237,6 +255,20 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox", project=project, is_default=True
|
||||
)
|
||||
|
||||
# Create the triage state in Backlog group
|
||||
State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=pk,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -373,7 +405,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
]
|
||||
)
|
||||
|
||||
## Delete joined project invites
|
||||
# Delete joined project invites
|
||||
project_invitations.delete()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
@@ -411,14 +443,23 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.data.get("role", 10) > project_member.role:
|
||||
# Check while updating user roles
|
||||
requested_project_member = ProjectMember.objects.get(
|
||||
project_id=project_id, workspace__slug=slug, member=request.user
|
||||
)
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role))
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
@@ -441,8 +482,72 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
try:
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
# check requesting user role
|
||||
requesting_project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, member=request.user, project_id=project_id
|
||||
)
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than yourself"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
assignee=project_member.member,
|
||||
).delete()
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=project_member.member,
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
owned_by=project_member.member,
|
||||
).delete()
|
||||
project_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member does not exist"}, status=status.HTTP_400
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"})
|
||||
|
||||
|
||||
class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
@@ -452,45 +557,47 @@ class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
member_id = request.data.get("member_id", False)
|
||||
role = request.data.get("role", False)
|
||||
members = request.data.get("members", [])
|
||||
|
||||
if not member_id or not role:
|
||||
# get the project
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
if not len(members):
|
||||
return Response(
|
||||
{"error": "Member ID and role is required"},
|
||||
{"error": "Atleast one member is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the user is a member in the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member_id=member_id
|
||||
).exists():
|
||||
# TODO: Update this error message - nk
|
||||
return Response(
|
||||
{
|
||||
"error": "User is not a member of the workspace. Invite the user to the workspace to add him to project"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if the user is already member of project
|
||||
if ProjectMember.objects.filter(
|
||||
project=project_id, member_id=member_id
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "User is already a member of the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Add the user to project
|
||||
project_member = ProjectMember.objects.create(
|
||||
project_id=project_id, member_id=member_id, role=role
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
member_id=member.get("member_id"),
|
||||
role=member.get("role", 10),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
for member in members
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_member)
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except KeyError:
|
||||
return Response(
|
||||
{"error": "Incorrect data sent"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "User not member of the workspace"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
|
||||
@@ -57,7 +57,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
@@ -206,28 +206,51 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
query = request.query_params.get("search", False)
|
||||
parent = request.query_params.get("parent", False)
|
||||
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
|
||||
parent = request.query_params.get("parent", "false")
|
||||
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", "false")
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = search_issues(query)
|
||||
issues = issues.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
|
||||
).exclude(
|
||||
pk__in=Issue.objects.filter(parent__isnull=False).values_list(
|
||||
pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list(
|
||||
"parent_id", flat=True
|
||||
)
|
||||
)
|
||||
if blocker_blocked_by == "true" and issue_id:
|
||||
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(blocked_issues__block=issue),
|
||||
~Q(blocker_issues__blocked_by=issue),
|
||||
)
|
||||
if sub_issue == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
|
||||
if issue.parent:
|
||||
issues = issues.filter(~Q(pk=issue.parent_id))
|
||||
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module == "true":
|
||||
issues = issues.exclude(issue_module__isnull=False)
|
||||
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
@@ -237,6 +260,9 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -245,7 +271,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from plane.api.serializers import ShortCutSerializer
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Shortcut
|
||||
|
||||
|
||||
class ShortCutViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ShortCutSerializer
|
||||
model = Shortcut
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
@@ -3,13 +3,13 @@ from itertools import groupby
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import StateSerializer
|
||||
@@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(~Q(name="Triage"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
@@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
try:
|
||||
state = State.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
~Q(name="Triage"),
|
||||
pk=pk, project_id=project_id, workspace__slug=slug,
|
||||
)
|
||||
|
||||
if state.default:
|
||||
@@ -89,7 +91,7 @@ class StateViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.objects.filter(state=pk).exists()
|
||||
issue_exist = Issue.issue_objects.filter(state=pk).exists()
|
||||
|
||||
if issue_exist:
|
||||
return Response(
|
||||
|
||||
@@ -67,7 +67,7 @@ class ViewIssuesEndpoint(BaseAPIView):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
**queries, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
.filter(**filters)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import jwt
|
||||
from datetime import date, datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
@@ -21,6 +22,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||
from django.db.models.fields import DateField
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
@@ -50,6 +52,14 @@ from plane.db.models import (
|
||||
IssueActivity,
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
IssueAssignee,
|
||||
ProjectFavorite,
|
||||
CycleFavorite,
|
||||
ModuleMember,
|
||||
ModuleFavorite,
|
||||
PageFavorite,
|
||||
Page,
|
||||
IssueViewFavorite,
|
||||
)
|
||||
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
@@ -72,14 +82,47 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
lookup_field = "slug"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super().get_queryset().select_related("owner")
|
||||
).order_by("name")
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
return (
|
||||
self.filter_queryset(super().get_queryset().select_related("owner"))
|
||||
.order_by("name")
|
||||
.filter(workspace_member__member=self.request.user)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.select_related("owner")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
serializer = WorkSpaceSerializer(data=request.data)
|
||||
|
||||
slug = request.data.get("slug", False)
|
||||
name = request.data.get("name", False)
|
||||
|
||||
if not name or not slug:
|
||||
return Response(
|
||||
{"error": "Both name and slug are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(name) > 80 or len(slug) > 48:
|
||||
return Response(
|
||||
{"error": "The maximum length for name is 80 and for slug is 48"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(owner=request.user)
|
||||
# Create Workspace member
|
||||
@@ -131,15 +174,28 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.prefetch_related(
|
||||
Prefetch("workspace_member", queryset=WorkspaceMember.objects.all())
|
||||
(
|
||||
Workspace.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"workspace_member", queryset=WorkspaceMember.objects.all()
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
workspace_member__member=request.user,
|
||||
)
|
||||
.select_related("owner")
|
||||
)
|
||||
.filter(
|
||||
workspace_member__member=request.user,
|
||||
)
|
||||
.select_related("owner")
|
||||
).annotate(total_members=member_count)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
)
|
||||
|
||||
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -187,6 +243,22 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# check for role level
|
||||
requesting_user = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
if len(
|
||||
[
|
||||
email
|
||||
for email in emails
|
||||
if int(email.get("role", 10)) > requesting_user.role
|
||||
]
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot invite a user with higher role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Check if user is already a member of workspace
|
||||
@@ -241,6 +313,21 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
email__in=[email.get("email") for email in emails]
|
||||
).select_related("workspace")
|
||||
|
||||
# create the user if signup is disabled
|
||||
if settings.DOCKERIZED and not settings.ENABLE_SIGNUP:
|
||||
_ = User.objects.bulk_create(
|
||||
[
|
||||
User(
|
||||
username=str(uuid4().hex),
|
||||
email=invitation.email,
|
||||
password=make_password(uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
for invitation in workspace_invitations
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
for invitation in workspace_invitations:
|
||||
workspace_invitation.delay(
|
||||
invitation.email,
|
||||
@@ -353,9 +440,33 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
try:
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
# delete the user if signup is disabled
|
||||
if settings.DOCKERIZED and not settings.ENABLE_SIGNUP:
|
||||
user = User.objects.filter(email=workspace_member_invite.email).first()
|
||||
if user is not None:
|
||||
user.delete()
|
||||
workspace_member_invite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except WorkspaceMemberInvite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace member invite does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
@@ -366,7 +477,8 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
.annotate(total_members=Count("workspace__workspace_member"))
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -432,7 +544,17 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.data.get("role", 10) > workspace_member.role:
|
||||
# Get the requested user role
|
||||
requested_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
# Check if role is being updated
|
||||
# One cannot update role higher than his own role
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", workspace_member.role))
|
||||
> requested_workspace_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
@@ -460,6 +582,69 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
try:
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk)
|
||||
|
||||
# check requesting user role
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete the user also from all the projects
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee=workspace_member.member
|
||||
).delete()
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, owned_by=workspace_member.member
|
||||
).delete()
|
||||
|
||||
workspace_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Member does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
@@ -662,7 +847,7 @@ class UserIssueCompletedGraphEndpoint(BaseAPIView):
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
@@ -707,7 +892,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
completed_issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
@@ -720,24 +905,24 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
.order_by("week_in_month")
|
||||
)
|
||||
|
||||
assigned_issues = Issue.objects.filter(
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
pending_issues_count = Issue.objects.filter(
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
completed_issues_count = Issue.objects.filter(
|
||||
completed_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
issues_due_week = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
@@ -747,14 +932,16 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user])
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
)
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
overdue_issues = Issue.objects.filter(
|
||||
overdue_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
@@ -762,7 +949,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
upcoming_issues = Issue.objects.filter(
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
|
||||
@@ -36,7 +36,7 @@ row_mapping = {
|
||||
def analytic_export_task(email, data, slug):
|
||||
try:
|
||||
filters = issue_filters(data, "POST")
|
||||
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
|
||||
queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug)
|
||||
|
||||
x_axis = data.get("x_axis", False)
|
||||
y_axis = data.get("y_axis", False)
|
||||
@@ -53,7 +53,7 @@ def analytic_export_task(email, data, slug):
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
@@ -169,6 +169,8 @@ def analytic_export_task(email, data, slug):
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -19,7 +19,7 @@ def email_verification(first_name, email, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = "/request-email-verification/" + "?token=" + str(token)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
@@ -39,5 +39,8 @@ def email_verification(first_name, email, token, current_site):
|
||||
msg.send()
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -16,12 +16,12 @@ from plane.db.models import User
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
realtivelink = f"/reset-password/?uidb64={uidb64}&token={token}"
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
subject = f"Reset Your Password - Plane"
|
||||
|
||||
context = {
|
||||
"first_name": first_name,
|
||||
@@ -37,5 +37,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
msg.send()
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -175,5 +175,8 @@ def service_importer(service, importer_id):
|
||||
importer = Importer.objects.get(pk=importer_id)
|
||||
importer.status = "failed"
|
||||
importer.save()
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import requests
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
@@ -20,6 +21,9 @@ from plane.db.models import (
|
||||
State,
|
||||
Cycle,
|
||||
Module,
|
||||
IssueSubscriber,
|
||||
Notification,
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.api.serializers import IssueActivitySerializer
|
||||
|
||||
@@ -44,7 +48,7 @@ def track_name(
|
||||
field="name",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
|
||||
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -554,6 +558,64 @@ def track_estimate_points(
|
||||
)
|
||||
|
||||
|
||||
def track_archive_at(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if requested_data.get("archived_at") is None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} has restored the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"Plane has archived the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_closed_to(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if requested_data.get("closed_to") is not None:
|
||||
updated_state = State.objects.get(
|
||||
pk=requested_data.get("closed_to"), project=project
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=None,
|
||||
new_value=updated_state.name,
|
||||
field="state",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"Plane updated the state to {updated_state.name}",
|
||||
old_identifier=None,
|
||||
new_identifier=updated_state.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
@@ -570,6 +632,8 @@ def update_issue_activity(
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"estimate_point": track_estimate_points,
|
||||
"archived_at": track_archive_at,
|
||||
"closed_to": track_closed_to,
|
||||
}
|
||||
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
@@ -950,7 +1014,13 @@ def delete_attachment_activity(
|
||||
# Receive message from room group
|
||||
@shared_task
|
||||
def issue_activity(
|
||||
type, requested_data, current_instance, issue_id, actor_id, project_id
|
||||
type,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
actor_id,
|
||||
project_id,
|
||||
subscriber=True,
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
@@ -958,6 +1028,27 @@ def issue_activity(
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
|
||||
|
||||
if issue is not None:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
"issue.activity.updated": update_issue_activity,
|
||||
@@ -992,19 +1083,101 @@ def issue_activity(
|
||||
# Post the updates to segway for integrations and webhooks
|
||||
if len(issue_activities_created):
|
||||
# Don't send activities if the actor is a bot
|
||||
if settings.PROXY_BASE_URL:
|
||||
try:
|
||||
if settings.PROXY_BASE_URL:
|
||||
for issue_activity in issue_activities_created:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
issue_activity_json = json.dumps(
|
||||
IssueActivitySerializer(issue_activity).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
|
||||
json=issue_activity_json,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
|
||||
|
||||
# Add bot filtering
|
||||
if (
|
||||
issue is not None
|
||||
and issue.created_by_id is not None
|
||||
and not issue.created_by.is_bot
|
||||
and str(issue.created_by_id) != str(actor_id)
|
||||
):
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
issue_activity_json = json.dumps(
|
||||
IssueActivitySerializer(issue_activity).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
|
||||
json=issue_activity_json,
|
||||
headers=headers,
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.id),
|
||||
"verb": str(issue_activity.verb),
|
||||
"field": str(issue_activity.field),
|
||||
"actor": str(issue_activity.actor_id),
|
||||
"new_value": str(issue_activity.new_value),
|
||||
"old_value": str(issue_activity.old_value),
|
||||
"issue_comment": str(
|
||||
issue_activity.issue_comment.comment_stripped
|
||||
if issue_activity.issue_comment is not None
|
||||
else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
157
apiserver/plane/bgtasks/issue_automation_task.py
Normal file
157
apiserver/plane/bgtasks/issue_automation_task.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
@shared_task
|
||||
def archive_and_close_old_issues():
|
||||
archive_old_issues()
|
||||
close_old_issues()
|
||||
|
||||
|
||||
def archive_old_issues():
|
||||
try:
|
||||
# Get all the projects whose archive_in is greater than 0
|
||||
projects = Project.objects.filter(archive_in__gt=0)
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
archive_in = project.archive_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the archive_in month
|
||||
issues = Issue.objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)),
|
||||
state__group__in=["completed", "cancelled"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.archived_at = timezone.now()
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def close_old_issues():
|
||||
try:
|
||||
# Get all the projects whose close_in is greater than 0
|
||||
projects = Project.objects.filter(close_in__gt=0).select_related(
|
||||
"default_state"
|
||||
)
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
close_in = project.close_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the close_in month
|
||||
issues = Issue.objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)),
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
if project.default_state is None:
|
||||
close_state = State.objects.filter(group="cancelled").first()
|
||||
else:
|
||||
close_state = project.default_state
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.state = close_state
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -13,7 +13,7 @@ from sentry_sdk import capture_exception
|
||||
def magic_link(email, key, token, current_site):
|
||||
try:
|
||||
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
@@ -31,4 +31,7 @@ def magic_link(email, key, token, current_site):
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
return
|
||||
|
||||
@@ -21,7 +21,7 @@ def project_invitation(email, project_id, token, current_site):
|
||||
)
|
||||
|
||||
relativelink = f"/project-member-invitation/{project_member_invite.id}"
|
||||
abs_url = "http://" + current_site + relativelink
|
||||
abs_url = current_site + relativelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
@@ -50,5 +50,8 @@ def project_invitation(email, project_id, token, current_site):
|
||||
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e:
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -29,5 +29,8 @@ def send_welcome_slack(user_id, created, message):
|
||||
print(f"Got an error: {e.response['error']}")
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -23,9 +23,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
|
||||
realtivelink = (
|
||||
f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}"
|
||||
f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}"
|
||||
)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
@@ -66,5 +66,8 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
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")
|
||||
@@ -13,5 +14,15 @@ app = Celery("plane")
|
||||
# pickle the object when using Windows.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
# Executes every day at 12 AM
|
||||
"check-every-day-to-archive-and-close": {
|
||||
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
|
||||
83
apiserver/plane/db/migrations/0033_auto_20230618_2125.py
Normal file
83
apiserver/plane/db/migrations/0033_auto_20230618_2125.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-18 15:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0032_auto_20230520_2015'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Inbox',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, verbose_name='Inbox Description')),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
('view_props', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inbox',
|
||||
'verbose_name_plural': 'Inboxes',
|
||||
'db_table': 'inboxes',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='inbox_view',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InboxIssue',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('status', models.IntegerField(choices=[(-2, 'Pending'), (-1, 'Rejected'), (0, 'Snoozed'), (1, 'Accepted'), (2, 'Duplicate')], default=-2)),
|
||||
('snoozed_till', models.DateTimeField(null=True)),
|
||||
('source', models.TextField(blank=True, null=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('duplicate_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_duplicate', to='db.issue')),
|
||||
('inbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.inbox')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inboxissue', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inboxissue', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'InboxIssue',
|
||||
'verbose_name_plural': 'InboxIssues',
|
||||
'db_table': 'inbox_issues',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inbox',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inbox', to='db.project'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inbox',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inbox',
|
||||
name='workspace',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inbox', to='db.workspace'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='inbox',
|
||||
unique_together={('name', 'project')},
|
||||
),
|
||||
]
|
||||
39
apiserver/plane/db/migrations/0034_auto_20230628_1046.py
Normal file
39
apiserver/plane/db/migrations/0034_auto_20230628_1046.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-28 05:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0033_auto_20230618_2125'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='timelineissue',
|
||||
name='created_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='timelineissue',
|
||||
name='issue',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='timelineissue',
|
||||
name='project',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='timelineissue',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='timelineissue',
|
||||
name='workspace',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Shortcut',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TimelineIssue',
|
||||
),
|
||||
]
|
||||
42
apiserver/plane/db/migrations/0035_auto_20230704_2225.py
Normal file
42
apiserver/plane/db/migrations/0035_auto_20230704_2225.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 3.2.19 on 2023-07-04 16:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def update_company_organization_size(apps, schema_editor):
|
||||
Model = apps.get_model("db", "Workspace")
|
||||
updated_size = []
|
||||
for obj in Model.objects.all():
|
||||
obj.organization_size = str(obj.company_size)
|
||||
updated_size.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0034_auto_20230628_1046"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workspace",
|
||||
name="organization_size",
|
||||
field=models.CharField(default="2-10", max_length=20),
|
||||
),
|
||||
migrations.RunPython(update_company_organization_size),
|
||||
migrations.AlterField(
|
||||
model_name="workspace",
|
||||
name="name",
|
||||
field=models.CharField(max_length=80, verbose_name="Workspace Name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workspace",
|
||||
name="slug",
|
||||
field=models.SlugField(max_length=48, unique=True),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="workspace",
|
||||
name="company_size",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.19 on 2023-07-05 07:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0035_auto_20230704_2225'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workspace',
|
||||
name='organization_size',
|
||||
field=models.CharField(max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,266 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-19 06:52
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.user
|
||||
import uuid
|
||||
|
||||
|
||||
|
||||
def onboarding_default_steps(apps, schema_editor):
|
||||
default_onboarding_schema = {
|
||||
"workspace_join": True,
|
||||
"profile_complete": True,
|
||||
"workspace_create": True,
|
||||
"workspace_invite": True,
|
||||
}
|
||||
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.filter(is_onboarded=True):
|
||||
obj.onboarding_step = default_onboarding_schema
|
||||
obj.is_tour_completed = True
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0036_alter_workspace_organization_size"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="issue",
|
||||
name="archived_at",
|
||||
field=models.DateField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="archive_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="close_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="default_state",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="default_state",
|
||||
to="db.state",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_tour_completed",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="onboarding_step",
|
||||
field=models.JSONField(default=plane.db.models.user.get_default_onboarding),
|
||||
),
|
||||
migrations.RunPython(onboarding_default_steps),
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(null=True)),
|
||||
("entity_identifier", models.UUIDField(null=True)),
|
||||
("entity_name", models.CharField(max_length=255)),
|
||||
("title", models.TextField()),
|
||||
("message", models.JSONField(null=True)),
|
||||
("message_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("message_stripped", models.TextField(blank=True, null=True)),
|
||||
("sender", models.CharField(max_length=255)),
|
||||
("read_at", models.DateTimeField(null=True)),
|
||||
("snoozed_till", models.DateTimeField(null=True)),
|
||||
("archived_at", models.DateTimeField(null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"receiver",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"triggered_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="triggered_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification",
|
||||
"verbose_name_plural": "Notifications",
|
||||
"db_table": "notifications",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="IssueSubscriber",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issue",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
to="db.issue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"subscriber",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Issue Subscriber",
|
||||
"verbose_name_plural": "Issue Subscribers",
|
||||
"db_table": "issue_subscribers",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("issue", "subscriber")},
|
||||
},
|
||||
),
|
||||
]
|
||||
35
apiserver/plane/db/migrations/0038_auto_20230720_1505.py
Normal file
35
apiserver/plane/db/migrations/0038_auto_20230720_1505.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-20 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def restructure_theming(apps, schema_editor):
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.exclude(theme={}).all():
|
||||
current_theme = obj.theme
|
||||
updated_theme = {
|
||||
"primary": current_theme.get("accent", ""),
|
||||
"background": current_theme.get("bgBase", ""),
|
||||
"sidebarBackground": current_theme.get("sidebar", ""),
|
||||
"text": current_theme.get("textBase", ""),
|
||||
"sidebarText": current_theme.get("textBase", ""),
|
||||
"palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""",
|
||||
"darkPalette": current_theme.get("darkPalette", "")
|
||||
}
|
||||
obj.theme = updated_theme
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_user, ["theme"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0037_issue_archived_at_project_archive_in_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(restructure_theming)
|
||||
]
|
||||
@@ -23,7 +23,6 @@ from .project import (
|
||||
from .issue import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
TimelineIssue,
|
||||
IssueProperty,
|
||||
IssueComment,
|
||||
IssueBlocker,
|
||||
@@ -34,6 +33,7 @@ from .issue import (
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -44,8 +44,6 @@ from .state import State
|
||||
|
||||
from .cycle import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
from .shortcut import Shortcut
|
||||
|
||||
from .view import IssueView, IssueViewFavorite
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
@@ -68,4 +66,8 @@ from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .analytic import AnalyticView
|
||||
from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
@@ -4,6 +4,7 @@ from uuid import uuid4
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import BaseModel
|
||||
@@ -16,8 +17,7 @@ def get_upload_path(instance, filename):
|
||||
|
||||
|
||||
def file_size(value):
|
||||
limit = 5 * 1024 * 1024
|
||||
if value.size > limit:
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
|
||||
51
apiserver/plane/db/models/inbox.py
Normal file
51
apiserver/plane/db/models/inbox.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
|
||||
|
||||
class Inbox(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Inbox Description", blank=True)
|
||||
is_default = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Inbox"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
verbose_name = "Inbox"
|
||||
verbose_name_plural = "Inboxes"
|
||||
db_table = "inboxes"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class InboxIssue(ProjectBaseModel):
|
||||
inbox = models.ForeignKey(
|
||||
"db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", related_name="issue_inbox", on_delete=models.CASCADE
|
||||
)
|
||||
status = models.IntegerField(
|
||||
choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")),
|
||||
default=-2,
|
||||
)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
duplicate_to = models.ForeignKey(
|
||||
"db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
source = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "InboxIssue"
|
||||
verbose_name_plural = "InboxIssues"
|
||||
db_table = "inbox_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Issue"""
|
||||
return f"{self.issue.name} <{self.inbox.name}>"
|
||||
@@ -17,6 +17,21 @@ from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
# TODO: Handle identifiers for Bulk Inserts - nk
|
||||
class IssueManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
models.Q(issue_inbox__status=1)
|
||||
| models.Q(issue_inbox__status=-1)
|
||||
| models.Q(issue_inbox__status=2)
|
||||
| models.Q(issue_inbox__isnull=True)
|
||||
)
|
||||
.exclude(archived_at__isnull=False)
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
@@ -67,6 +82,10 @@ class Issue(ProjectBaseModel):
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
|
||||
objects = models.Manager()
|
||||
issue_objects = IssueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
@@ -81,11 +100,13 @@ class Issue(ProjectBaseModel):
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
project=self.project, default=True
|
||||
~models.Q(name="Triage"), project=self.project, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
random_state = State.objects.filter(project=self.project).first()
|
||||
random_state = State.objects.filter(
|
||||
~models.Q(name="Triage"), project=self.project
|
||||
).first()
|
||||
self.state = random_state
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
@@ -210,8 +231,8 @@ def get_upload_path(instance, filename):
|
||||
|
||||
|
||||
def file_size(value):
|
||||
limit = 5 * 1024 * 1024
|
||||
if value.size > limit:
|
||||
# File limit check is only for cloud hosted
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
@@ -276,24 +297,6 @@ class IssueActivity(ProjectBaseModel):
|
||||
return str(self.issue)
|
||||
|
||||
|
||||
class TimelineIssue(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_timeline"
|
||||
)
|
||||
sequence_id = models.FloatField(default=1.0)
|
||||
links = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Timeline Issue"
|
||||
verbose_name_plural = "Timeline Issues"
|
||||
db_table = "issue_timelines"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return project of the project member"""
|
||||
return str(self.issue)
|
||||
|
||||
|
||||
class IssueComment(ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
@@ -400,6 +403,27 @@ class IssueSequence(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class IssueSubscriber(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_subscribers"
|
||||
)
|
||||
subscriber = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "subscriber"]
|
||||
verbose_name = "Issue Subscriber"
|
||||
verbose_name_plural = "Issue Subscribers"
|
||||
db_table = "issue_subscribers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.subscriber.email}"
|
||||
|
||||
|
||||
# TODO: Find a better method to save the model
|
||||
@receiver(post_save, sender=Issue)
|
||||
def create_issue_sequence(sender, instance, created, **kwargs):
|
||||
|
||||
37
apiserver/plane/db/models/notification.py
Normal file
37
apiserver/plane/db/models/notification.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="notifications", on_delete=models.CASCADE
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", related_name="notifications", on_delete=models.CASCADE, null=True
|
||||
)
|
||||
data = models.JSONField(null=True)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=255)
|
||||
title = models.TextField()
|
||||
message = models.JSONField(null=True)
|
||||
message_html = models.TextField(blank=True, default="<p></p>")
|
||||
message_stripped = models.TextField(blank=True, null=True)
|
||||
sender = models.CharField(max_length=255)
|
||||
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
|
||||
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
|
||||
read_at = models.DateTimeField(null=True)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Notification"
|
||||
verbose_name_plural = "Notifications"
|
||||
db_table = "notifications"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
return f"{self.receiver.email} <{self.workspace.name}>"
|
||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
# Modeule imports
|
||||
from plane.db.mixins import AuditModel
|
||||
@@ -69,10 +70,20 @@ class Project(BaseModel):
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
page_view = models.BooleanField(default=True)
|
||||
inbox_view = models.BooleanField(default=False)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
|
||||
)
|
||||
archive_in = models.IntegerField(
|
||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||
)
|
||||
close_in = models.IntegerField(
|
||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||
)
|
||||
default_state = models.ForeignKey(
|
||||
"db.State", on_delete=models.SET_NULL, null=True, related_name="default_state"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
|
||||
|
||||
class Shortcut(ProjectBaseModel):
|
||||
TYPE_CHOICES = (("repo", "Repo"), ("direct", "Direct"))
|
||||
name = models.CharField(max_length=255, verbose_name="Cycle Name")
|
||||
description = models.TextField(verbose_name="Cycle Description", blank=True)
|
||||
type = models.CharField(
|
||||
max_length=255, verbose_name="Shortcut Type", choices=TYPE_CHOICES
|
||||
)
|
||||
url = models.URLField(verbose_name="URL", blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Shortcut"
|
||||
verbose_name_plural = "Shortcuts"
|
||||
db_table = "shortcuts"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the shortcut"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
@@ -18,6 +18,13 @@ from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
"workspace_create": False,
|
||||
"workspace_invite": False,
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(
|
||||
@@ -73,6 +80,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
theme = models.JSONField(default=dict)
|
||||
is_tour_completed = models.BooleanField(default=False)
|
||||
onboarding_step = models.JSONField(default=get_default_onboarding)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ ROLE_CHOICES = (
|
||||
|
||||
|
||||
class Workspace(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Workspace Name")
|
||||
name = models.CharField(max_length=80, verbose_name="Workspace Name")
|
||||
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owner_workspace",
|
||||
)
|
||||
slug = models.SlugField(max_length=100, db_index=True, unique=True)
|
||||
company_size = models.PositiveIntegerField(default=10)
|
||||
slug = models.SlugField(max_length=48, db_index=True, unique=True)
|
||||
organization_size = models.CharField(max_length=20)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
|
||||
@@ -35,6 +35,7 @@ INSTALLED_APPS = [
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"django_celery_beat",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -179,6 +180,7 @@ EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1"
|
||||
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1"
|
||||
EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>")
|
||||
|
||||
|
||||
@@ -212,3 +214,4 @@ SIMPLE_JWT = {
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
|
||||
|
||||
@@ -10,22 +10,26 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
DEBUG = True
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": "plane",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
"HOST": "",
|
||||
"HOST": os.environ.get("PGHOST", "localhost"),
|
||||
}
|
||||
}
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
if DOCKERIZED:
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
@@ -55,7 +59,29 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
else:
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
"loggers": {
|
||||
"*": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
@@ -68,14 +94,15 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
if DOCKERIZED:
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||
WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000")
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
@@ -84,5 +111,6 @@ LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
|
||||
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
@@ -13,10 +13,11 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = False
|
||||
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "plane",
|
||||
"USER": os.environ.get("PGUSER", ""),
|
||||
"PASSWORD": os.environ.get("PGPASSWORD", ""),
|
||||
@@ -29,9 +30,12 @@ DATABASES = {
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
SITE_ID = 1
|
||||
|
||||
DOCKERIZED = os.environ.get(
|
||||
"DOCKERIZED", False
|
||||
) # Set the variable true if running in docker-compose environment
|
||||
# Set the variable true if running in docker environment
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
@@ -66,10 +70,14 @@ CORS_ALLOW_HEADERS = [
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
if bool(os.environ.get("SENTRY_DSN", False)):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
@@ -78,14 +86,32 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
if (
|
||||
os.environ.get("AWS_REGION", False)
|
||||
and os.environ.get("AWS_ACCESS_KEY_ID", False)
|
||||
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
|
||||
and os.environ.get("AWS_S3_BUCKET_NAME", False)
|
||||
):
|
||||
if DOCKERIZED and USE_MINIO:
|
||||
INSTALLED_APPS += ("storages",)
|
||||
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
# The name of the bucket to store files in.
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
|
||||
)
|
||||
# Default permissions
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# Custom Domain settings
|
||||
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
else:
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
|
||||
@@ -99,7 +125,7 @@ if (
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
@@ -166,14 +192,11 @@ if (
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
|
||||
else:
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||
}
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
@@ -187,9 +210,6 @@ ALLOWED_HOSTS = [
|
||||
]
|
||||
|
||||
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
@@ -218,22 +238,17 @@ else:
|
||||
}
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
"USE_REDIS_CACHE": "default",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
@@ -252,3 +267,6 @@ else:
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
@@ -11,15 +11,16 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = True
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": "plane",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
"HOST": "",
|
||||
"HOST": os.environ.get("PGHOST", "localhost"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +47,18 @@ ALLOWED_HOSTS = ["*"]
|
||||
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Make true if running in a docker environment
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
@@ -58,6 +67,7 @@ sentry_sdk.init(
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="staging",
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
# The AWS region to connect to.
|
||||
@@ -142,7 +152,9 @@ AWS_S3_SIGNATURE_VERSION = None
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||
}
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
@@ -155,17 +167,11 @@ ALLOWED_HOSTS = [
|
||||
"*",
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
@@ -192,17 +198,23 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
broker_url = (
|
||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
)
|
||||
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
"""
|
||||
|
||||
# from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import path, include, re_path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
|
||||
# from django.conf.urls.static import static
|
||||
|
||||
@@ -17,13 +16,11 @@ urlpatterns = [
|
||||
path("api/", include("plane.api.urls")),
|
||||
path("", include("plane.web.urls")),
|
||||
]
|
||||
# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^__debug__/", include(debug_toolbar.urls)),
|
||||
re_path(r"^__debug__/", include(debug_toolbar.urls)),
|
||||
] + urlpatterns
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
from datetime import timedelta
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
|
||||
@@ -74,3 +79,69 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
else:
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||
return sorted_data
|
||||
|
||||
|
||||
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
# Total Issues in Cycle or Module
|
||||
total_issues = queryset.total_issues
|
||||
|
||||
|
||||
if cycle_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
for x in range((queryset.end_date - queryset.start_date).days + 1)
|
||||
]
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
completed_issues_distribution = (
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.annotate(total_completed=Count("id"))
|
||||
.values("date", "total_completed")
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
if module_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
for x in range((queryset.target_date - queryset.start_date).days + 1)
|
||||
]
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
completed_issues_distribution = (
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_module__module_id=module_id,
|
||||
)
|
||||
.annotate(date=TruncDate("completed_at"))
|
||||
.values("date")
|
||||
.annotate(total_completed=Count("id"))
|
||||
.values("date", "total_completed")
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
|
||||
for date in date_range:
|
||||
cumulative_pending_issues = total_issues
|
||||
total_completed = 0
|
||||
total_completed = sum(
|
||||
[
|
||||
item["total_completed"]
|
||||
for item in completed_issues_distribution
|
||||
if item["date"] is not None and item["date"] <= date
|
||||
]
|
||||
)
|
||||
cumulative_pending_issues -= total_completed
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
|
||||
return chart_data
|
||||
@@ -113,7 +113,7 @@ def get_github_repo_details(access_tokens_url, owner, repo):
|
||||
last_url = labels_response.links.get("last").get("url")
|
||||
parsed_url = urlparse(last_url)
|
||||
last_page_value = parse_qs(parsed_url.query)["page"][0]
|
||||
total_labels = total_labels + 100 * (last_page_value - 1)
|
||||
total_labels = total_labels + 100 * (int(last_page_value) - 1)
|
||||
|
||||
# Get labels in last page
|
||||
last_page_labels = requests.get(last_url, headers=headers).json()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
|
||||
def filter_state(params, filter, method):
|
||||
if method == "GET":
|
||||
states = params.get("state").split(",")
|
||||
@@ -26,12 +25,27 @@ def filter_estimate_point(params, filter, method):
|
||||
|
||||
def filter_priority(params, filter, method):
|
||||
if method == "GET":
|
||||
priorties = params.get("priority").split(",")
|
||||
if len(priorties) and "" not in priorties:
|
||||
filter["priority__in"] = priorties
|
||||
priorities = params.get("priority").split(",")
|
||||
if len(priorities) and "" not in priorities:
|
||||
if len(priorities) == 1 and "null" in priorities:
|
||||
filter["priority__isnull"] = True
|
||||
elif len(priorities) > 1 and "null" in priorities:
|
||||
filter["priority__isnull"] = True
|
||||
filter["priority__in"] = [p for p in priorities if p != "null"]
|
||||
else:
|
||||
filter["priority__in"] = [p for p in priorities if p != "null"]
|
||||
|
||||
else:
|
||||
if params.get("priority", None) and len(params.get("priority")):
|
||||
filter["priority__in"] = params.get("priority")
|
||||
priorities = params.get("priority")
|
||||
if len(priorities) == 1 and "null" in priorities:
|
||||
filter["priority__isnull"] = True
|
||||
elif len(priorities) > 1 and "null" in priorities:
|
||||
filter["priority__isnull"] = True
|
||||
filter["priority__in"] = [p for p in priorities if p != "null"]
|
||||
else:
|
||||
filter["priority__in"] = [p for p in priorities if p != "null"]
|
||||
|
||||
return filter
|
||||
|
||||
|
||||
@@ -152,16 +166,16 @@ def filter_target_date(params, filter, method):
|
||||
for query in target_dates:
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gte"] = target_date_query[0]
|
||||
filter["target_date__gt"] = target_date_query[0]
|
||||
else:
|
||||
filter["target_date__lte"] = target_date_query[0]
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
for query in params.get("target_date"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["target_date__gte"] = query.get("datetime")
|
||||
filter["target_date__gt"] = query.get("datetime")
|
||||
else:
|
||||
filter["target_date__lte"] = query.get("datetime")
|
||||
filter["target_date__lt"] = query.get("datetime")
|
||||
|
||||
return filter
|
||||
|
||||
@@ -231,6 +245,29 @@ def filter_module(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_inbox_status(params, filter, method):
|
||||
if method == "GET":
|
||||
status = params.get("inbox_status").split(",")
|
||||
if len(status) and "" not in status:
|
||||
filter["issue_inbox__status__in"] = status
|
||||
else:
|
||||
if params.get("inbox_status", None) and len(params.get("inbox_status")):
|
||||
filter["issue_inbox__status__in"] = params.get("inbox_status")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_sub_issue_toggle(params, filter, method):
|
||||
if method == "GET":
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
filter["parent__isnull"] = True
|
||||
else:
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
filter["parent__isnull"] = True
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
|
||||
@@ -252,6 +289,8 @@ def issue_filters(query_params, method):
|
||||
"project": filter_project,
|
||||
"cycle": filter_cycle,
|
||||
"module": filter_module,
|
||||
"inbox_status": filter_inbox_status,
|
||||
"sub_issue": filter_sub_issue_toggle,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db.models import Q
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
def search_issues(query):
|
||||
def search_issues(query, queryset):
|
||||
fields = ["name", "sequence_id"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
@@ -18,6 +18,6 @@ def search_issues(query):
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Issue.objects.filter(
|
||||
return queryset.filter(
|
||||
q,
|
||||
).distinct()
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
# base requirements
|
||||
|
||||
Django==3.2.19
|
||||
Django==4.2.3
|
||||
django-braces==1.15.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
django-oauth-toolkit==2.2.0
|
||||
mistune==2.0.4
|
||||
django-taggit==4.0.0
|
||||
psycopg==3.1.9
|
||||
django-oauth-toolkit==2.3.0
|
||||
mistune==3.0.1
|
||||
djangorestframework==3.14.0
|
||||
redis==4.5.4
|
||||
redis==4.6.0
|
||||
django-nested-admin==4.0.2
|
||||
django-cors-headers==3.13.0
|
||||
whitenoise==6.3.0
|
||||
django-allauth==0.52.0
|
||||
faker==13.4.0
|
||||
django-filter==22.1
|
||||
django-cors-headers==4.1.0
|
||||
whitenoise==6.5.0
|
||||
django-allauth==0.54.0
|
||||
faker==18.11.2
|
||||
django-filter==23.2
|
||||
jsonmodels==2.6.0
|
||||
djangorestframework-simplejwt==5.2.2
|
||||
sentry-sdk==1.14.0
|
||||
django-s3-storage==0.13.11
|
||||
sentry-sdk==1.27.0
|
||||
django-s3-storage==0.14.0
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.16.0
|
||||
google-api-python-client==2.75.0
|
||||
django-redis==5.2.0
|
||||
uvicorn==0.20.0
|
||||
google-auth==2.21.0
|
||||
google-api-python-client==2.92.0
|
||||
django-redis==5.3.0
|
||||
uvicorn==0.22.0
|
||||
channels==4.0.0
|
||||
openai==0.27.2
|
||||
slack-sdk==3.20.2
|
||||
celery==5.2.7
|
||||
openai==0.27.8
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
@@ -1,3 +1,3 @@
|
||||
-r base.txt
|
||||
|
||||
django-debug-toolbar==3.8.1
|
||||
django-debug-toolbar==4.1.0
|
||||
@@ -1,12 +1,11 @@
|
||||
-r base.txt
|
||||
|
||||
dj-database-url==1.2.0
|
||||
dj-database-url==2.0.0
|
||||
gunicorn==20.1.0
|
||||
whitenoise==6.3.0
|
||||
whitenoise==6.5.0
|
||||
django-storages==1.13.2
|
||||
boto==2.49.0
|
||||
django-anymail==9.0
|
||||
twilio==7.16.2
|
||||
django-debug-toolbar==3.8.1
|
||||
gevent==22.10.2
|
||||
boto3==1.27.0
|
||||
django-anymail==10.0
|
||||
django-debug-toolbar==4.1.0
|
||||
gevent==23.7.0
|
||||
psycogreen==1.0.2
|
||||
@@ -1 +1 @@
|
||||
python-3.11.3
|
||||
python-3.11.4
|
||||
@@ -1,11 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<p>
|
||||
|
||||
<body>
|
||||
<p>
|
||||
Dear {{first_name}},<br /><br />
|
||||
Welcome! Your account has been created.
|
||||
Verify your email by clicking on the link below <br />
|
||||
{{forgot_password_url}}
|
||||
successfully.<br /><br />
|
||||
</p>
|
||||
We received a request to reset your password for your Plane account.
|
||||
<br /><br />
|
||||
To proceed with resetting your password, please click on the link below:
|
||||
<br />
|
||||
<a href="{{forgot_password_url}}">{{forgot_password_url}}</a>
|
||||
<br /><br />
|
||||
If you didn't request to reset your password, please ignore this email. Your account will remain secure.
|
||||
<br /><br />
|
||||
If you have any questions or need further assistance, please contact our support team.
|
||||
<br /><br />
|
||||
Thank you for using Plane.
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
app.json
26
app.json
@@ -37,6 +37,14 @@
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_FROM": {
|
||||
"description": "Email Sender",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_PORT": {
|
||||
"description": "The default Email PORT to use",
|
||||
"value": "587"
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"description": "AWS Region to use for S3",
|
||||
"value": "false"
|
||||
@@ -49,30 +57,22 @@
|
||||
"description": "AWS Secret Access Key to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_S3_BUCKET_NAME": {
|
||||
"description": "AWS Bucket Name to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "",
|
||||
"value": ""
|
||||
},
|
||||
"WEB_URL": {
|
||||
"description": "Web URL for Plane",
|
||||
"description": "Web URL for Plane this will be used for redirections in the emails",
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "Github Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_GITHUB_ID": {
|
||||
"description": "Next Public Github ID",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_GOOGLE_CLIENTID": {
|
||||
"description": "Next Public Google Client ID",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
"description": "Next Public API Base URL",
|
||||
"value": ""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
rules: {
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
@@ -14,7 +13,6 @@ RUN turbo prune --scope=app --docker
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
@@ -22,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
|
||||
@@ -16,11 +16,12 @@ type EmailCodeFormValues = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeResent, setCodeResent] = useState(false);
|
||||
const [isCodeResending, setIsCodeResending] = useState(false);
|
||||
const [errorResendingCode, setErrorResendingCode] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
|
||||
@@ -31,6 +32,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailCodeFormValues>({
|
||||
defaultValues: {
|
||||
@@ -64,12 +66,14 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
};
|
||||
|
||||
const handleSignin = async (formData: EmailCodeFormValues) => {
|
||||
setIsLoading(true);
|
||||
await authenticationService
|
||||
.magicSignIn(formData)
|
||||
.then((response) => {
|
||||
onSuccess(response);
|
||||
handleSignIn(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
@@ -77,7 +81,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error.error,
|
||||
message: error?.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -88,45 +92,56 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
setErrorResendingCode(false);
|
||||
}, [emailOld]);
|
||||
|
||||
useEffect(() => {
|
||||
const submitForm = (e: KeyboardEvent) => {
|
||||
if (!codeSent && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!codeSent) {
|
||||
window.addEventListener("keydown", submitForm);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", submitForm);
|
||||
};
|
||||
}, [handleSubmit, codeSent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5 py-5 px-5">
|
||||
{(codeSent || codeResent) && (
|
||||
<div className="rounded-md bg-green-500/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-green-500">
|
||||
{codeResent
|
||||
? "Please check your mail for new code."
|
||||
: "Please check your mail for code."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{(codeSent || codeResent) && (
|
||||
<p className="text-center mt-4">
|
||||
We have sent the sign in code.
|
||||
<br />
|
||||
Please check your inbox at <span className="font-medium">{watch("email")}</span>
|
||||
</p>
|
||||
)}
|
||||
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email ID is required",
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email ID is not valid",
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your Email ID"
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{codeSent && (
|
||||
<div>
|
||||
<>
|
||||
<Input
|
||||
id="token"
|
||||
type="token"
|
||||
@@ -136,14 +151,15 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
required: "Code is required",
|
||||
}}
|
||||
error={errors.token}
|
||||
placeholder="Enter code"
|
||||
placeholder="Enter code..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`mt-5 flex w-full justify-end text-xs outline-none ${
|
||||
className={`flex w-full justify-end text-xs outline-none ${
|
||||
isResendDisabled
|
||||
? "cursor-default text-brand-secondary"
|
||||
: "cursor-pointer text-brand-accent"
|
||||
? "cursor-default text-custom-text-200"
|
||||
: "cursor-pointer text-custom-primary-100"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
@@ -156,46 +172,43 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
{resendCodeTimer > 0 ? (
|
||||
<p className="text-right">
|
||||
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
|
||||
</p>
|
||||
<span className="text-right">Request new code in {resendCodeTimer} seconds</span>
|
||||
) : isCodeResending ? (
|
||||
"Sending code..."
|
||||
"Sending new code..."
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
"Resend code"
|
||||
<span className="font-medium">Resend code</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{codeSent ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send sign in code"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
<div>
|
||||
{codeSent ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send code"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailResetPasswordForm } from "components/account";
|
||||
// ui
|
||||
import { Input, SecondaryButton } from "components/ui";
|
||||
import { Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
type EmailPasswordFormValues = {
|
||||
email: string;
|
||||
@@ -17,12 +16,19 @@ type EmailPasswordFormValues = {
|
||||
medium?: string;
|
||||
};
|
||||
|
||||
export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
const { setToastAlert } = useToast();
|
||||
type Props = {
|
||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const isSignUpPage = router.pathname === "/sign-up";
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailPasswordFormValues>({
|
||||
defaultValues: {
|
||||
@@ -34,83 +40,96 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = (formData: EmailPasswordFormValues) => {
|
||||
authenticationService
|
||||
.emailLogin(formData)
|
||||
.then((response) => {
|
||||
onSuccess(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: "Enter the correct email address and password to sign in",
|
||||
});
|
||||
if (!error?.response?.data) return;
|
||||
Object.keys(error.response.data).forEach((key) => {
|
||||
const err = error.response.data[key];
|
||||
console.log(err);
|
||||
setError(key as keyof EmailPasswordFormValues, {
|
||||
type: "manual",
|
||||
message: Array.isArray(err) ? err.join(", ") : err,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email ID is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email ID is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your Email ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="ml-auto text-sm">
|
||||
<Link href={"/forgot-password"}>
|
||||
<a className="font-medium text-brand-accent hover:text-brand-accent">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
{isResettingPassword
|
||||
? "Reset your password"
|
||||
: isSignUpPage
|
||||
? "Sign up on Plane"
|
||||
: "Sign in to Plane"}
|
||||
</h1>
|
||||
{isResettingPassword ? (
|
||||
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<SecondaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
{isSignUpPage ? (
|
||||
<Link href="/">
|
||||
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
||||
Already have an account? Sign in.
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResettingPassword(true)}
|
||||
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSignUpPage
|
||||
? isSubmitting
|
||||
? "Signing up..."
|
||||
: "Sign up"
|
||||
: isSubmitting
|
||||
? "Signing in..."
|
||||
: "Sign in"}
|
||||
</PrimaryButton>
|
||||
{!isSignUpPage && (
|
||||
<Link href="/sign-up">
|
||||
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
|
||||
Don{"'"}t have an account? Sign up.
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
97
apps/app/components/account/email-reset-password-form.tsx
Normal file
97
apps/app/components/account/email-reset-password-form.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
type Props = {
|
||||
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const forgotPassword = async (formData: any) => {
|
||||
const payload = {
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
await userService
|
||||
.forgotPassword(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Password reset link has been sent to your email address.",
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.status === 400)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please check the Email ID entered.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||
onSubmit={handleSubmit(forgotPassword)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter registered email address.."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||
<SecondaryButton
|
||||
className="w-full text-center h-[46px]"
|
||||
onClick={() => setIsResettingPassword(false)}
|
||||
>
|
||||
Go Back
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useState, FC } from "react";
|
||||
import { KeyIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import { EmailCodeForm, EmailPasswordForm } from "components/account";
|
||||
|
||||
export interface EmailSignInFormProps {
|
||||
handleSuccess: () => void;
|
||||
}
|
||||
|
||||
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
|
||||
const { handleSuccess } = props;
|
||||
// states
|
||||
const [useCode, setUseCode] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{useCode ? (
|
||||
<EmailCodeForm onSuccess={handleSuccess} />
|
||||
) : (
|
||||
<EmailPasswordForm onSuccess={handleSuccess} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useEffect, useState, FC } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// images
|
||||
import githubImage from "/public/logos/github-black.png";
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
|
||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
||||
|
||||
@@ -11,35 +16,42 @@ export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
// router
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
const {
|
||||
query: { code },
|
||||
} = useRouter();
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
if (code && !gitCode) {
|
||||
setGitCode(code.toString());
|
||||
handleSignIn(code.toString());
|
||||
}
|
||||
}, [code, handleSignIn]);
|
||||
}, [code, gitCode, handleSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/signin` as any);
|
||||
setLoginCallBackURL(`${origin}/` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full px-1">
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base p-2 text-sm font-medium text-brand-secondary duration-300 hover:bg-brand-surface-2">
|
||||
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
|
||||
<span>Sign In with Github</span>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span>Sign in with GitHub</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
// next
|
||||
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
@@ -8,18 +8,18 @@ export interface IGoogleLoginButton {
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
window?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
window?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
{
|
||||
@@ -27,11 +27,13 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
width: "410",
|
||||
text: "continue_with",
|
||||
width: "360",
|
||||
text: "signin_with",
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
|
||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded]);
|
||||
|
||||
@@ -47,7 +49,11 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div className="overflow-hidden rounded" id="googleSignInButton" ref={googleSignInButton} />
|
||||
<div
|
||||
className="overflow-hidden rounded w-full flex justify-center items-center !text-sm !font-medium !text-custom-text-100"
|
||||
id="googleSignInButton"
|
||||
ref={googleSignInButton}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./google-login";
|
||||
export * from "./email-code-form";
|
||||
export * from "./email-password-form";
|
||||
export * from "./email-reset-password-form";
|
||||
export * from "./github-login-button";
|
||||
export * from "./email-signin-form";
|
||||
export * from "./google-login";
|
||||
|
||||
@@ -97,7 +97,7 @@ export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClos
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -111,10 +111,13 @@ export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClos
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Save Analytics
|
||||
</Dialog.Title>
|
||||
<div className="mt-5">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Loader, PrimaryButton } from "components/ui";
|
||||
// helpers
|
||||
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
@@ -29,6 +29,7 @@ type Props = {
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
fullScreen: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const CustomAnalytics: React.FC<Props> = ({
|
||||
@@ -38,6 +39,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
control,
|
||||
setValue,
|
||||
fullScreen,
|
||||
user,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@@ -59,7 +61,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
<AnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
projects={projects}
|
||||
projects={projects ?? []}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
@@ -84,7 +86,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<div className="space-y-4 text-custom-text-200">
|
||||
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +104,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<div className="space-y-4 text-custom-text-200">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton
|
||||
@@ -124,6 +126,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
@@ -39,7 +39,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium text-brand-secondary ${
|
||||
className={`font-medium text-custom-text-200 ${
|
||||
params.segment
|
||||
? params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
|
||||
@@ -111,7 +111,6 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
||||
: undefined,
|
||||
}}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
>
|
||||
{!isProjectLevel && (
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Project</h6>
|
||||
<h6 className="text-xs text-custom-text-200">Project</h6>
|
||||
<Controller
|
||||
name="project"
|
||||
control={control}
|
||||
@@ -40,7 +40,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
|
||||
<h6 className="text-xs text-custom-text-200">Measure (y-axis)</h6>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
@@ -50,7 +50,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
|
||||
<h6 className="text-xs text-custom-text-200">Dimension (x-axis)</h6>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
@@ -67,7 +67,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Group</h6>
|
||||
<h6 className="text-xs text-custom-text-200">Group</h6>
|
||||
<Controller
|
||||
name="segment"
|
||||
control={control}
|
||||
|
||||
@@ -7,6 +7,7 @@ import analyticsService from "services/analytics.service";
|
||||
import projectService from "services/project.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useToast from "hooks/use-toast";
|
||||
@@ -22,8 +23,16 @@ import {
|
||||
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortDate } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
|
||||
import {
|
||||
IAnalyticsParams,
|
||||
IAnalyticsResponse,
|
||||
ICurrentUserResponse,
|
||||
IExportAnalyticsFormData,
|
||||
IWorkspace,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// constants
|
||||
@@ -34,6 +43,7 @@ type Props = {
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
@@ -41,6 +51,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
params,
|
||||
fullScreen,
|
||||
isProjectLevel = false,
|
||||
user,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
@@ -82,6 +93,60 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
: null
|
||||
);
|
||||
|
||||
const trackExportAnalytics = () => {
|
||||
const eventPayload: any = {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
params: {
|
||||
x_axis: params.x_axis,
|
||||
y_axis: params.y_axis,
|
||||
group: params.segment,
|
||||
project: params.project,
|
||||
},
|
||||
};
|
||||
|
||||
if (projectDetails) {
|
||||
const workspaceDetails = projectDetails.workspace as IWorkspace;
|
||||
|
||||
eventPayload.workspaceId = workspaceDetails.id;
|
||||
eventPayload.workspaceName = workspaceDetails.name;
|
||||
eventPayload.projectId = projectDetails.id;
|
||||
eventPayload.projectIdentifier = projectDetails.identifier;
|
||||
eventPayload.projectName = projectDetails.name;
|
||||
}
|
||||
|
||||
if (cycleDetails || moduleDetails) {
|
||||
const details = cycleDetails || moduleDetails;
|
||||
|
||||
eventPayload.workspaceId = details?.workspace_detail?.id;
|
||||
eventPayload.workspaceName = details?.workspace_detail?.name;
|
||||
eventPayload.projectId = details?.project_detail.id;
|
||||
eventPayload.projectIdentifier = details?.project_detail.identifier;
|
||||
eventPayload.projectName = details?.project_detail.name;
|
||||
}
|
||||
|
||||
if (cycleDetails) {
|
||||
eventPayload.cycleId = cycleDetails.id;
|
||||
eventPayload.cycleName = cycleDetails.name;
|
||||
}
|
||||
|
||||
if (moduleDetails) {
|
||||
eventPayload.moduleId = moduleDetails.id;
|
||||
eventPayload.moduleName = moduleDetails.name;
|
||||
}
|
||||
|
||||
trackEventServices.trackAnalyticsEvent(
|
||||
eventPayload,
|
||||
cycleId
|
||||
? "CYCLE_ANALYTICS_EXPORT"
|
||||
: moduleId
|
||||
? "MODULE_ANALYTICS_EXPORT"
|
||||
: projectId
|
||||
? "PROJECT_ANALYTICS_EXPORT"
|
||||
: "WORKSPACE_ANALYTICS_EXPORT",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const exportAnalytics = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
@@ -95,13 +160,15 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
|
||||
analyticsService
|
||||
.exportAnalytics(workspaceSlug.toString(), data)
|
||||
.then((res) =>
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: res.message,
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
trackExportAnalytics();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@@ -112,23 +179,23 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const selectedProjects =
|
||||
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
|
||||
params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
|
||||
fullScreen
|
||||
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
|
||||
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
|
||||
<LayerDiagonalIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} Issues
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
{renderShortDate(
|
||||
(cycleId
|
||||
@@ -140,7 +207,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
@@ -148,61 +215,62 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
<h4 className="font-medium">Selected Projects</h4>
|
||||
<div className="space-y-6 mt-4 h-full overflow-y-auto">
|
||||
{selectedProjects.map((projectId) => {
|
||||
const project: IProject = projects.find((p) => p.id === projectId);
|
||||
const project = projects?.find((p) => p.id === projectId);
|
||||
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(project.emoji))}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
if (project)
|
||||
return (
|
||||
<div key={project.id} className="w-full">
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="break-all">
|
||||
{project.name}
|
||||
<span className="text-brand-secondary text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 pl-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_members}</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="flex items-center gap-1">
|
||||
<p className="break-words">{truncateText(project.name, 20)}</p>
|
||||
<span className="text-custom-text-200 text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
<div className="mt-4 space-y-3 pl-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_members}</span>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total modules</h6>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total modules</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_modules}</span>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_modules}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,16 +278,16 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
{projectId ? (
|
||||
cycleId && cycleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
|
||||
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<h6 className="text-custom-text-200">Lead</h6>
|
||||
<span>
|
||||
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<h6 className="text-custom-text-200">Start Date</h6>
|
||||
<span>
|
||||
{cycleDetails.start_date && cycleDetails.start_date !== ""
|
||||
? renderShortDate(cycleDetails.start_date)
|
||||
@@ -227,7 +295,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<h6 className="text-custom-text-200">Target Date</h6>
|
||||
<span>
|
||||
{cycleDetails.end_date && cycleDetails.end_date !== ""
|
||||
? renderShortDate(cycleDetails.end_date)
|
||||
@@ -238,17 +306,17 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
) : moduleId && moduleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
|
||||
<h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<h6 className="text-custom-text-200">Lead</h6>
|
||||
<span>
|
||||
{moduleDetails.lead_detail?.first_name}{" "}
|
||||
{moduleDetails.lead_detail?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<h6 className="text-custom-text-200">Start Date</h6>
|
||||
<span>
|
||||
{moduleDetails.start_date && moduleDetails.start_date !== ""
|
||||
? renderShortDate(moduleDetails.start_date)
|
||||
@@ -256,7 +324,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<h6 className="text-custom-text-200">Target Date</h6>
|
||||
<span>
|
||||
{moduleDetails.target_date && moduleDetails.target_date !== ""
|
||||
? renderShortDate(moduleDetails.target_date)
|
||||
@@ -270,7 +338,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
<div className="flex items-center gap-1">
|
||||
{projectDetails?.emoji ? (
|
||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(projectDetails.emoji))}
|
||||
{renderEmoji(projectDetails.emoji)}
|
||||
</div>
|
||||
) : projectDetails?.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
@@ -286,11 +354,11 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
{projectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
|
||||
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Network</h6>
|
||||
<h6 className="text-custom-text-200">Network</h6>
|
||||
<span>
|
||||
{
|
||||
NETWORK_CHOICES[
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user