Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+6 |
68dfef51f6 | ||
|
|
8118974a37 | ||
|
|
6966666bf5 | ||
|
|
7e92efee23 | ||
|
|
848fb2b960 | ||
|
|
155c392467 | ||
|
|
2e9b77cbdc | ||
|
|
eba72fd5bc | ||
|
|
4a9b1723ec | ||
|
|
d2df3330fa | ||
|
|
fbb045436c | ||
|
|
0cb4edab54 | ||
|
|
eaa77a2552 | ||
|
|
7278b5727f | ||
|
|
09292025df | ||
|
|
60a35e6af1 | ||
|
|
27e3364a1f | ||
|
|
28d4f4c876 | ||
|
|
df96efca3e | ||
|
|
45a52116a7 | ||
|
|
0ff5f363ee | ||
|
|
fcf23b985b | ||
|
|
6b89ee2a55 | ||
|
|
7ca1aef2ad | ||
|
|
46973149bf | ||
|
|
f87a9e9d3a | ||
|
|
143ba75604 | ||
|
|
2cb708c63b | ||
|
|
8767816966 | ||
|
|
d108dd28a0 | ||
|
|
30c59545f0 | ||
|
|
cedc884d92 | ||
|
|
f931d6ffd9 | ||
|
|
9075f9441c | ||
|
|
9134b0c543 | ||
|
|
0fd96d592d | ||
|
|
3036014ea2 | ||
|
|
6e99c007a5 | ||
|
|
502e15ddf0 | ||
|
|
e20d30daf2 | ||
|
|
acf7f59eef | ||
|
|
894e26116b | ||
|
|
f12b7ef923 | ||
|
|
1f1472b00c | ||
|
|
c33fc0e0ec |
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
// This tells ESLint to load the config from the package `eslint-config-custom`
|
||||
extends: ["custom"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["apps/*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -62,3 +62,11 @@ yarn-error.log
|
||||
*.sln
|
||||
package-lock.json
|
||||
.vscode
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
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
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
|
||||
FROM python:3.11.1-alpine3.17 AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
"xmlsec~=1.2" \
|
||||
"nginx" \
|
||||
"nodejs" \
|
||||
"npm" \
|
||||
"supervisor"
|
||||
|
||||
COPY apiserver/requirements.txt ./
|
||||
COPY apiserver/requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
"cargo~=1.64" \
|
||||
"git~=2" \
|
||||
"make~=4.3" \
|
||||
"postgresql13-dev~=13" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
&& \
|
||||
pip install -r requirements.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
apk del .build-deps
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY apiserver/manage.py manage.py
|
||||
COPY apiserver/plane plane/
|
||||
COPY apiserver/templates templates/
|
||||
|
||||
COPY apiserver/gunicorn.config.py ./
|
||||
RUN apk --update --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
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 plane
|
||||
RUN adduser --system --uid 1001 captain
|
||||
|
||||
COPY --from=installer /app/apps/app/next.config.js .
|
||||
COPY --from=installer /app/apps/app/package.json .
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# RUN rm /etc/nginx/conf.d/default.conf
|
||||
#######################################################################
|
||||
COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
|
||||
#######################################################################
|
||||
|
||||
COPY nginx/supervisor.conf /code/supervisor.conf
|
||||
|
||||
|
||||
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/29tPNhaV">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
</a>
|
||||
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
@@ -48,4 +48,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 security@plane.so to disclose any security vulnerabilities.
|
||||
|
||||
+12
-8
@@ -1,18 +1,22 @@
|
||||
# Backend
|
||||
SECRET_KEY="<-- django secret -->"
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
# Database
|
||||
DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
EMAIL_HOST="<-- email smtp -->"
|
||||
EMAIL_HOST_USER="<-- email host user -->"
|
||||
EMAIL_HOST_PASSWORD="<-- email host password -->"
|
||||
|
||||
# AWS
|
||||
AWS_REGION="<-- aws region -->"
|
||||
AWS_ACCESS_KEY_ID="<-- aws access key -->"
|
||||
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
|
||||
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
|
||||
|
||||
SENTRY_DSN="<-- sentry dsn -->"
|
||||
WEB_URL="<-- frontend web url -->"
|
||||
|
||||
# FE
|
||||
WEB_URL="localhost/"
|
||||
# OAUTH
|
||||
GITHUB_CLIENT_SECRET="<-- github secret -->"
|
||||
|
||||
# Flags
|
||||
DISABLE_COLLECTSTATIC=1
|
||||
DOCKERIZED=0 //True if running docker compose else 0
|
||||
DOCKERIZED=1
|
||||
|
||||
+11
-10
@@ -1,4 +1,4 @@
|
||||
FROM python:3.8.14-alpine3.16 AS backend
|
||||
FROM python:3.11.1-alpine3.17 AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
@@ -8,19 +8,19 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
"libpq~=14" \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=18" \
|
||||
"nodejs-current~=19" \
|
||||
"xmlsec~=1.2"
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
"bash~=5.1" \
|
||||
"g++~=11.2" \
|
||||
"gcc~=11.2" \
|
||||
"cargo~=1.60" \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
"cargo~=1.64" \
|
||||
"git~=2" \
|
||||
"make~=4.3" \
|
||||
"postgresql13-dev~=13" \
|
||||
@@ -46,15 +46,16 @@ COPY templates templates/
|
||||
|
||||
COPY gunicorn.config.py ./
|
||||
USER root
|
||||
RUN apk --update --no-cache add "bash~=5.1"
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
|
||||
CMD [ "./bin/takeoff" ]
|
||||
# CMD [ "./bin/takeoff" ]
|
||||
|
||||
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
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: python manage.py rqworker
|
||||
channel-worker: python manage.py runworker issue-activites
|
||||
worker: python manage.py rqworker
|
||||
@@ -1,11 +1,14 @@
|
||||
# All the python scripts that are used for back migrations
|
||||
import uuid
|
||||
import random
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from plane.db.models import ProjectIdentifier
|
||||
from plane.db.models import Issue, IssueComment, User
|
||||
|
||||
from plane.db.models import Issue, IssueComment
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
def update_description():
|
||||
try:
|
||||
|
||||
issues = Issue.objects.all()
|
||||
updated_issues = []
|
||||
|
||||
@@ -25,7 +28,6 @@ def update_description():
|
||||
|
||||
def update_comments():
|
||||
try:
|
||||
|
||||
issue_comments = IssueComment.objects.all()
|
||||
updated_issue_comments = []
|
||||
|
||||
@@ -40,3 +42,57 @@ def update_comments():
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_project_identifiers():
|
||||
try:
|
||||
project_identifiers = ProjectIdentifier.objects.filter(
|
||||
workspace_id=None
|
||||
).select_related("project", "project__workspace")
|
||||
updated_identifiers = []
|
||||
|
||||
for identifier in project_identifiers:
|
||||
identifier.workspace_id = identifier.project.workspace_id
|
||||
updated_identifiers.append(identifier)
|
||||
|
||||
ProjectIdentifier.objects.bulk_update(
|
||||
updated_identifiers, ["workspace_id"], batch_size=50
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_user_empty_password():
|
||||
try:
|
||||
users = User.objects.filter(password="")
|
||||
updated_users = []
|
||||
|
||||
for user in users:
|
||||
user.password = make_password(uuid.uuid4().hex)
|
||||
user.is_password_autoset = True
|
||||
updated_users.append(user)
|
||||
|
||||
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
|
||||
print("Success")
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def updated_issue_sort_order():
|
||||
try:
|
||||
issues = Issue.objects.all()
|
||||
updated_issues = []
|
||||
|
||||
for issue in issues:
|
||||
issue.sort_order = issue.sequence_id * random.randint(100, 500)
|
||||
updated_issues.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
python manage.py migrate
|
||||
python manage.py runworker issue-activites
|
||||
@@ -2,4 +2,8 @@
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
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 -
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import os, sys
|
||||
import uuid
|
||||
|
||||
sys.path.append("/code")
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
def populate():
|
||||
default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so")
|
||||
default_password = os.environ.get("DEFAULT_PASSWORD", "password123")
|
||||
|
||||
if not User.objects.filter(email=default_email).exists():
|
||||
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
|
||||
user.set_password(default_password)
|
||||
user.save()
|
||||
print("User created")
|
||||
|
||||
print("Success")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
populate()
|
||||
@@ -1 +0,0 @@
|
||||
from .issue_consumer import IssueConsumer
|
||||
@@ -1,547 +0,0 @@
|
||||
from channels.generic.websocket import SyncConsumer
|
||||
import json
|
||||
from plane.db.models import IssueActivity, Project, User, Issue, State, Label
|
||||
|
||||
|
||||
class IssueConsumer(SyncConsumer):
|
||||
|
||||
# Track Chnages in name
|
||||
def track_name(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("name") != requested_data.get("name"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("name"),
|
||||
new_value=requested_data.get("name"),
|
||||
field="name",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in parent issue
|
||||
def track_parent(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("parent") != requested_data.get("parent"):
|
||||
|
||||
if requested_data.get("parent") == None:
|
||||
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}",
|
||||
new_value=None,
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the parent issue to None",
|
||||
old_identifier=old_parent.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
|
||||
old_parent = Issue.objects.filter(
|
||||
pk=current_instance.get("parent")
|
||||
).first()
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}"
|
||||
if old_parent is not None
|
||||
else None,
|
||||
new_value=f"{project.identifier}-{new_parent.sequence_id}",
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
|
||||
old_identifier=old_parent.id
|
||||
if old_parent is not None
|
||||
else None,
|
||||
new_identifier=new_parent.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in priority
|
||||
def track_priority(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("priority") != requested_data.get("priority"):
|
||||
if requested_data.get("priority") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("parent"),
|
||||
new_value=requested_data.get("parent"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the priority to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
)
|
||||
|
||||
# Track chnages in state of the issue
|
||||
def track_state(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("state") != requested_data.get("state"):
|
||||
|
||||
new_state = State.objects.get(pk=requested_data.get("state", None))
|
||||
old_state = State.objects.get(pk=current_instance.get("state", None))
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=old_state.name,
|
||||
new_value=new_state.name,
|
||||
field="state",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the state to {new_state.name}",
|
||||
old_identifier=old_state.id,
|
||||
new_identifier=new_state.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track issue description
|
||||
def track_description(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("description_html") != requested_data.get("description_html"):
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in issue target date
|
||||
def track_target_date(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("target_date") != requested_data.get("target_date"):
|
||||
if requested_data.get("target_date") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("target_date"),
|
||||
new_value=requested_data.get("target_date"),
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the target date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("target_date"),
|
||||
new_value=requested_data.get("target_date"),
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in issue start date
|
||||
def track_start_date(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("start_date") != requested_data.get("start_date"):
|
||||
if requested_data.get("start_date") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("start_date"),
|
||||
new_value=requested_data.get("start_date"),
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("start_date"),
|
||||
new_value=requested_data.get("start_date"),
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in issue labels
|
||||
def track_labels(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
# Label Addition
|
||||
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
|
||||
|
||||
for label in requested_data.get("labels_list"):
|
||||
if label not in current_instance.get("labels"):
|
||||
label = Label.objects.get(pk=label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=label.name,
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added label {label.name}",
|
||||
new_identifier=label.id,
|
||||
old_identifier=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Label Removal
|
||||
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
|
||||
|
||||
for label in current_instance.get("labels"):
|
||||
if label not in requested_data.get("labels_list"):
|
||||
label = Label.objects.get(pk=label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=label.name,
|
||||
new_value="",
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed label {label.name}",
|
||||
old_identifier=label.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in issue assignees
|
||||
def track_assignees(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
|
||||
# Assignee Addition
|
||||
if len(requested_data.get("assignees_list")) > len(
|
||||
current_instance.get("assignees")
|
||||
):
|
||||
|
||||
for assignee in requested_data.get("assignees_list"):
|
||||
if assignee not in current_instance.get("assignees"):
|
||||
assignee = User.objects.get(pk=assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=assignee.email,
|
||||
field="assignees",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added assignee {assignee.email}",
|
||||
new_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Assignee Removal
|
||||
if len(requested_data.get("assignees_list")) < len(
|
||||
current_instance.get("assignees")
|
||||
):
|
||||
|
||||
for assignee in current_instance.get("assignees"):
|
||||
if assignee not in requested_data.get("assignees_list"):
|
||||
assignee = User.objects.get(pk=assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=assignee.email,
|
||||
new_value="",
|
||||
field="assignee",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed assignee {assignee.email}",
|
||||
old_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in blocking issues
|
||||
def track_blocks(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if len(requested_data.get("blocks_list")) > len(
|
||||
current_instance.get("blocked_issues")
|
||||
):
|
||||
|
||||
for block in requested_data.get("blocks_list"):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
blocked
|
||||
for blocked in current_instance.get("blocked_issues")
|
||||
if blocked.get("block") == block
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
issue = Issue.objects.get(pk=block)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
new_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Blocked Issue Removal
|
||||
if len(requested_data.get("blocks_list")) < len(
|
||||
current_instance.get("blocked_issues")
|
||||
):
|
||||
|
||||
for blocked in current_instance.get("blocked_issues"):
|
||||
if blocked.get("block") not in requested_data.get("blocks_list"):
|
||||
issue = Issue.objects.get(pk=blocked.get("block"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
old_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track changes in blocked_by issues
|
||||
def track_blockings(
|
||||
self,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if len(requested_data.get("blockers_list")) > len(
|
||||
current_instance.get("blocker_issues")
|
||||
):
|
||||
|
||||
for block in requested_data.get("blockers_list"):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
blocked
|
||||
for blocked in current_instance.get("blocker_issues")
|
||||
if blocked.get("blocked_by") == block
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
issue = Issue.objects.get(pk=block)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
new_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Blocked Issue Removal
|
||||
if len(requested_data.get("blockers_list")) < len(
|
||||
current_instance.get("blocker_issues")
|
||||
):
|
||||
|
||||
for blocked in current_instance.get("blocker_issues"):
|
||||
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
|
||||
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
old_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Receive message from room group
|
||||
def issue_activity(self, event):
|
||||
|
||||
issue_activities = []
|
||||
# Remove event type:
|
||||
event.pop("type")
|
||||
|
||||
requested_data = json.loads(event.get("requested_data"))
|
||||
current_instance = json.loads(event.get("current_instance"))
|
||||
issue_id = event.get("issue_id")
|
||||
actor_id = event.get("actor_id")
|
||||
project_id = event.get("project_id")
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": self.track_name,
|
||||
"parent": self.track_parent,
|
||||
"priority": self.track_priority,
|
||||
"state": self.track_state,
|
||||
"description": self.track_description,
|
||||
"target_date": self.track_target_date,
|
||||
"start_date": self.track_start_date,
|
||||
"labels_list": self.track_labels,
|
||||
"assignees_list": self.track_assignees,
|
||||
"blocks_list": self.track_blocks,
|
||||
"blockers_list": self.track_blockings,
|
||||
}
|
||||
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
|
||||
# Save all the values to database
|
||||
IssueActivity.objects.bulk_create(issue_activities)
|
||||
@@ -4,6 +4,12 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
# Module import
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Viewer = 10
|
||||
Guest = 5
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
@@ -13,16 +19,24 @@ class ProjectBasePermission(BasePermission):
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user
|
||||
).exists()
|
||||
|
||||
## Only workspace owners or admins can create the projects
|
||||
if request.method == "POST":
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace=view.workspace, member=request.user, role__in=[15, 20]
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace=view.workspace, member=request.user, role=20
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -34,16 +48,23 @@ class ProjectMemberPermission(BasePermission):
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user
|
||||
).exists()
|
||||
## Only workspace owners or admins can create the projects
|
||||
if request.method == "POST":
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace=view.workspace, member=request.user, role__in=[15, 20]
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace=view.workspace, member=request.user, role__in=[15, 20]
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -52,12 +73,19 @@ class ProjectEntityPermission(BasePermission):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
## Only workspace owners or admins can create the projects
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
## Only project members or admins can create and edit the project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
workspace=view.workspace, member=request.user, role__in=[15, 20]
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceMember, ProjectMember
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
|
||||
|
||||
# Permission Mappings
|
||||
Owner = 20
|
||||
Admin = 15
|
||||
Member = 10
|
||||
Guest = 5
|
||||
|
||||
|
||||
# TODO: Move the below logic to python match - python v3.10
|
||||
@@ -22,13 +30,15 @@ class WorkSpaceBasePermission(BasePermission):
|
||||
# allow only admins and owners to update the workspace settings
|
||||
if request.method in ["PUT", "PATCH"]:
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace=view.workspace, role__in=[15, 20]
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
).exists()
|
||||
|
||||
# allow only owner to delete the workspace
|
||||
if request.method == "DELETE":
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace=view.workspace, role=20
|
||||
member=request.user, workspace__slug=view.workspace_slug, role=Owner
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -39,5 +49,7 @@ class WorkSpaceAdminPermission(BasePermission):
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace=view.workspace, role__in=[15, 20]
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
).exists()
|
||||
|
||||
@@ -29,7 +29,6 @@ from .issue import (
|
||||
IssueCommentSerializer,
|
||||
TimeLineIssueSerializer,
|
||||
IssuePropertySerializer,
|
||||
IssueLabelSerializer,
|
||||
BlockerIssueSerializer,
|
||||
BlockedIssueSerializer,
|
||||
IssueAssigneeSerializer,
|
||||
@@ -39,4 +38,15 @@ from .issue import (
|
||||
IssueStateSerializer,
|
||||
)
|
||||
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
|
||||
from .api_token import APITokenSerializer
|
||||
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import APIToken
|
||||
|
||||
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = APIToken
|
||||
fields = "__all__"
|
||||
@@ -1,3 +1,6 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
@@ -22,6 +25,7 @@ class CycleSerializer(BaseSerializer):
|
||||
class CycleIssueSerializer(BaseSerializer):
|
||||
|
||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CycleIssue
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Module imports
|
||||
from plane.api.serializers import BaseSerializer
|
||||
from plane.db.models import Integration, WorkspaceIntegration
|
||||
|
||||
|
||||
class IntegrationSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"verified",
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceIntegrationSerializer(BaseSerializer):
|
||||
integration_detail = IntegrationSerializer(read_only=True, source="integration")
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceIntegration
|
||||
fields = "__all__"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Module imports
|
||||
from plane.api.serializers import BaseSerializer
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositorySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubRepository
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubRepositorySyncSerializer(BaseSerializer):
|
||||
repo_detail = GithubRepositorySerializer(source="repository")
|
||||
|
||||
class Meta:
|
||||
model = GithubRepositorySync
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubIssueSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubIssueSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
]
|
||||
|
||||
|
||||
class GithubCommentSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubCommentSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
"issue_sync",
|
||||
]
|
||||
@@ -24,9 +24,15 @@ from plane.db.models import (
|
||||
Cycle,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
)
|
||||
|
||||
|
||||
class IssueLinkCreateSerializer(serializers.Serializer):
|
||||
url = serializers.CharField(required=True)
|
||||
title = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
## Contain only flat fields
|
||||
|
||||
@@ -40,24 +46,13 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"sort_order",
|
||||
]
|
||||
|
||||
|
||||
# Issue Serializer with state details
|
||||
class IssueStateSerializer(BaseSerializer):
|
||||
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
##TODO: Find a better way to write this serializer
|
||||
## Find a better approach to save manytomany?
|
||||
class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
@@ -87,6 +82,11 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
links_list = serializers.ListField(
|
||||
child=IssueLinkCreateSerializer(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -105,6 +105,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
project = self.context["project"]
|
||||
issue = Issue.objects.create(**validated_data, project=project)
|
||||
@@ -173,14 +174,32 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return issue
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
blockers = validated_data.pop("blockers_list", None)
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if blockers is not None:
|
||||
IssueBlocker.objects.filter(block=instance).delete()
|
||||
@@ -250,11 +269,29 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.filter(issue=instance).delete()
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=instance,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueActivitySerializer(BaseSerializer):
|
||||
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
class Meta:
|
||||
@@ -263,7 +300,6 @@ class IssueActivitySerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
@@ -319,7 +355,6 @@ class LabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
# label_details = LabelSerializer(read_only=True, source="label")
|
||||
|
||||
class Meta:
|
||||
@@ -332,7 +367,6 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class BlockedIssueSerializer(BaseSerializer):
|
||||
|
||||
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -341,7 +375,6 @@ class BlockedIssueSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class BlockerIssueSerializer(BaseSerializer):
|
||||
|
||||
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -350,7 +383,6 @@ class BlockerIssueSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueAssigneeSerializer(BaseSerializer):
|
||||
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignee")
|
||||
|
||||
class Meta:
|
||||
@@ -373,7 +405,6 @@ class CycleBaseSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueCycleDetailSerializer(BaseSerializer):
|
||||
|
||||
cycle_detail = CycleBaseSerializer(read_only=True, source="cycle")
|
||||
|
||||
class Meta:
|
||||
@@ -404,7 +435,6 @@ class ModuleBaseSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueModuleDetailSerializer(BaseSerializer):
|
||||
|
||||
module_detail = ModuleBaseSerializer(read_only=True, source="module")
|
||||
|
||||
class Meta:
|
||||
@@ -420,6 +450,26 @@ class IssueModuleDetailSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueLinkSerializer(BaseSerializer):
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
# 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)
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
@@ -432,6 +482,8 @@ class IssueSerializer(BaseSerializer):
|
||||
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
|
||||
issue_cycle = IssueCycleDetailSerializer(read_only=True)
|
||||
issue_module = IssueModuleDetailSerializer(read_only=True)
|
||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
|
||||
@@ -93,7 +93,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if members is not None:
|
||||
ModuleIssue.objects.filter(module=instance).delete()
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
ModuleMember.objects.bulk_create(
|
||||
[
|
||||
ModuleMember(
|
||||
@@ -150,6 +150,7 @@ class ModuleIssueSerializer(BaseSerializer):
|
||||
|
||||
module_detail = ModuleFlatSerializer(read_only=True, source="module")
|
||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModuleIssue
|
||||
@@ -200,4 +201,4 @@ class ModuleSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ class UserSerializer(BaseSerializer):
|
||||
"last_login_uagent",
|
||||
"token_updated_at",
|
||||
"is_onboarded",
|
||||
"is_bot",
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
@@ -34,7 +35,9 @@ class UserLiteSerializer(BaseSerializer):
|
||||
"last_name",
|
||||
"email",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_bot",
|
||||
]
|
||||
|
||||
+182
-38
@@ -4,67 +4,98 @@ from django.urls import path
|
||||
# Create your urls here.
|
||||
|
||||
from plane.api.views import (
|
||||
# Authentication
|
||||
SignInEndpoint,
|
||||
SignOutEndpoint,
|
||||
MagicSignInEndpoint,
|
||||
MagicSignInGenerateEndpoint,
|
||||
OauthEndpoint,
|
||||
## End Authentication
|
||||
# Auth Extended
|
||||
ForgotPasswordEndpoint,
|
||||
PeopleEndpoint,
|
||||
UserEndpoint,
|
||||
VerifyEmailEndpoint,
|
||||
ResetPasswordEndpoint,
|
||||
RequestEmailVerificationEndpoint,
|
||||
OauthEndpoint,
|
||||
ChangePasswordEndpoint,
|
||||
)
|
||||
|
||||
from plane.api.views import (
|
||||
UserWorkspaceInvitationsEndpoint,
|
||||
## End Auth Extender
|
||||
# User
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
## End User
|
||||
# Workspaces
|
||||
WorkSpaceViewSet,
|
||||
UserWorkspaceInvitationsEndpoint,
|
||||
UserWorkSpacesEndpoint,
|
||||
InviteWorkspaceEndpoint,
|
||||
JoinWorkspaceEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
WorkspaceInvitationsViewset,
|
||||
UserWorkspaceInvitationsEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
TeamMemberViewSet,
|
||||
AddTeamToProjectEndpoint,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
UserWorkspaceInvitationEndpoint,
|
||||
## End Workspaces
|
||||
# File Assets
|
||||
FileAssetEndpoint,
|
||||
## End File Assets
|
||||
# Projects
|
||||
ProjectViewSet,
|
||||
InviteProjectEndpoint,
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberInvitationsViewset,
|
||||
StateViewSet,
|
||||
ShortCutViewSet,
|
||||
ViewViewSet,
|
||||
CycleViewSet,
|
||||
FileAssetEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
AddMemberToProjectEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectIdentifierEndpoint,
|
||||
## End Projects
|
||||
# Issues
|
||||
IssueViewSet,
|
||||
WorkSpaceIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueCommentViewSet,
|
||||
TeamMemberViewSet,
|
||||
TimeLineIssueViewSet,
|
||||
CycleIssueViewSet,
|
||||
IssuePropertyViewSet,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UserWorkspaceInvitationEndpoint,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectIdentifierEndpoint,
|
||||
LabelViewSet,
|
||||
AddMemberToProjectEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
TimeLineIssueViewSet,
|
||||
IssuePropertyViewSet,
|
||||
LabelViewSet,
|
||||
SubIssuesEndpoint,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
## End States
|
||||
# Shortcuts
|
||||
ShortCutViewSet,
|
||||
## End Shortcuts
|
||||
# Views
|
||||
ViewViewSet,
|
||||
## End Views
|
||||
# Cycles
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
## End Cycles
|
||||
# Modules
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
ProjectMemberUserEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
## End Modules
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
## End Api Tokens
|
||||
# Integrations
|
||||
IntegrationViewSet,
|
||||
WorkspaceIntegrationViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
## End Integrations
|
||||
)
|
||||
|
||||
from plane.api.views.project import AddTeamToProjectEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Social Auth
|
||||
@@ -95,8 +126,6 @@ urlpatterns = [
|
||||
ForgotPasswordEndpoint.as_view(),
|
||||
name="forgot-password",
|
||||
),
|
||||
# List Users
|
||||
path("users/", PeopleEndpoint.as_view()),
|
||||
# User Profile
|
||||
path(
|
||||
"users/me/",
|
||||
@@ -521,6 +550,11 @@ urlpatterns = [
|
||||
UserWorkSpaceIssues.as_view(),
|
||||
name="workspace-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||
SubIssuesEndpoint.as_view(),
|
||||
name="sub-issues",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
@@ -654,9 +688,119 @@ urlpatterns = [
|
||||
name="project-module-issues",
|
||||
),
|
||||
## End Modules
|
||||
# path(
|
||||
# "issues/<int:pk>/all/",
|
||||
# IssueViewSet.as_view({"get": "list_issue_history_comments"}),
|
||||
# name="Issue history and comments",
|
||||
# ),
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
## End API Tokens
|
||||
# Integrations
|
||||
path(
|
||||
"integrations/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"integrations/<uuid:pk>/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
# Github Integrations
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
|
||||
GithubRepositoriesEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
## End Github Integrations
|
||||
## End Integrations
|
||||
]
|
||||
|
||||
@@ -13,7 +13,6 @@ from .project import (
|
||||
ProjectMemberUserEndpoint,
|
||||
)
|
||||
from .people import (
|
||||
PeopleEndpoint,
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
)
|
||||
@@ -52,6 +51,7 @@ from .issue import (
|
||||
LabelViewSet,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
SubIssuesEndpoint,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -71,3 +71,14 @@ from .authentication import (
|
||||
)
|
||||
|
||||
from .module import ModuleViewSet, ModuleIssueViewSet
|
||||
|
||||
from .api_token import ApiTokenEndpoint
|
||||
|
||||
from .integration import (
|
||||
WorkspaceIntegrationViewSet,
|
||||
IntegrationViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module import
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken
|
||||
from plane.api.serializers import APITokenSerializer
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
workspace = request.data.get("workspace", False)
|
||||
|
||||
if not workspace:
|
||||
return Response(
|
||||
{"error": "Workspace is required"}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=label, user=request.user, workspace_id=workspace
|
||||
)
|
||||
|
||||
serializer = APITokenSerializer(api_token)
|
||||
return Response(serializer.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 get(self, request):
|
||||
try:
|
||||
api_tokens = APIToken.objects.filter(user=request.user)
|
||||
serializer = APITokenSerializer(api_tokens, 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 delete(self, request, pk):
|
||||
try:
|
||||
api_token = APIToken.objects.get(pk=pk)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except APIToken.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Token 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,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.db.models import FileAsset
|
||||
from plane.api.serializers import FileAssetSerializer
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
A viewset for viewing and editing task instances.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
files = FileAsset.objects.all()
|
||||
def get(self, request, slug):
|
||||
files = FileAsset.objects.filter(workspace__slug=slug)
|
||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class ForgotPasswordEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"messgae": "Check your email to reset your password"},
|
||||
{"message": "Check your email to reset your password"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -34,74 +35,6 @@ def get_tokens_for_user(user):
|
||||
)
|
||||
|
||||
|
||||
class SignUpEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
if user is not None:
|
||||
return Response(
|
||||
{"error": "Email ID is already taken"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = User.objects.create(email=email)
|
||||
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,
|
||||
}
|
||||
|
||||
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 or contact the support team."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class SignInEndpoint(BaseAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@@ -127,50 +60,69 @@ class SignInEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = User.objects.get(email=email)
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
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,
|
||||
)
|
||||
# Sign up Process
|
||||
if user is None:
|
||||
user = User.objects.create(email=email, username=uuid.uuid4().hex)
|
||||
user.set_password(password)
|
||||
|
||||
serialized_user = UserSerializer(user).data
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
serialized_user = UserSerializer(user).data
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
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)
|
||||
# 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)
|
||||
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@@ -216,14 +168,12 @@ class SignOutEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class MagicSignInGenerateEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
|
||||
email = request.data.get("email", False)
|
||||
|
||||
if not email:
|
||||
@@ -269,7 +219,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
|
||||
ri.set(key, json.dumps(value), ex=expiry)
|
||||
|
||||
else:
|
||||
|
||||
value = {"current_attempt": 0, "email": email, "token": token}
|
||||
expiry = 600
|
||||
|
||||
@@ -293,14 +242,12 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class MagicSignInEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
|
||||
user_token = request.data.get("token", "").strip().lower()
|
||||
key = request.data.get("key", False)
|
||||
|
||||
@@ -313,19 +260,20 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
ri = redis_instance()
|
||||
|
||||
if ri.exists(key):
|
||||
|
||||
data = json.loads(ri.get(key))
|
||||
|
||||
token = data["token"]
|
||||
email = data["email"]
|
||||
|
||||
if str(token) == str(user_token):
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
user = User.objects.get(email=email)
|
||||
else:
|
||||
user = User.objects.create(
|
||||
email=email, username=uuid.uuid4().hex
|
||||
email=email,
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
user.last_active = timezone.now()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.urls import resolve
|
||||
from django.conf import settings
|
||||
|
||||
# Third part imports
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
@@ -39,32 +40,23 @@ class BaseViewSet(ModelViewSet, BasePaginator):
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise APIException(
|
||||
"Please check the view", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
if settings.DEBUG:
|
||||
from django.db import connection
|
||||
print(f'# of Queries: {len(connection.queries)}')
|
||||
|
||||
print(
|
||||
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
|
||||
)
|
||||
return response
|
||||
|
||||
@property
|
||||
def workspace_slug(self):
|
||||
return self.kwargs.get("slug", None)
|
||||
|
||||
@property
|
||||
def workspace(self):
|
||||
if self.workspace_slug:
|
||||
try:
|
||||
return Workspace.objects.get(slug=self.workspace_slug)
|
||||
except Workspace.DoesNotExist:
|
||||
raise NotFound(detail="Workspace does not exist")
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
project_id = self.kwargs.get("project_id", None)
|
||||
@@ -74,16 +66,6 @@ class BaseViewSet(ModelViewSet, BasePaginator):
|
||||
if resolve(self.request.path_info).url_name == "project":
|
||||
return self.kwargs.get("pk", None)
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
if self.project_id:
|
||||
try:
|
||||
return Project.objects.get(pk=self.project_id)
|
||||
except Project.DoesNotExist:
|
||||
raise NotFound(detail="Project does not exist")
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class BaseAPIView(APIView, BasePaginator):
|
||||
|
||||
@@ -110,33 +92,16 @@ class BaseAPIView(APIView, BasePaginator):
|
||||
|
||||
if settings.DEBUG:
|
||||
from django.db import connection
|
||||
print(f'# of Queries: {len(connection.queries)}')
|
||||
|
||||
print(
|
||||
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
|
||||
)
|
||||
return response
|
||||
|
||||
@property
|
||||
def workspace_slug(self):
|
||||
return self.kwargs.get("slug", None)
|
||||
|
||||
@property
|
||||
def workspace(self):
|
||||
if self.workspace_slug:
|
||||
try:
|
||||
return Workspace.objects.get(slug=self.workspace_slug)
|
||||
except Workspace.DoesNotExist:
|
||||
raise NotFound(detail="Workspace does not exist")
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
return self.kwargs.get("project_id", None)
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
if self.project_id:
|
||||
try:
|
||||
return Project.objects.get(pk=self.project_id)
|
||||
except Project.DoesNotExist:
|
||||
raise NotFound(detail="Project does not exist")
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import OuterRef, Func, F
|
||||
from django.core import serializers
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -8,10 +15,10 @@ from . import BaseViewSet
|
||||
from plane.api.serializers import CycleSerializer, CycleIssueSerializer
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Cycle, CycleIssue, Issue
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = CycleSerializer
|
||||
model = Cycle
|
||||
permission_classes = [
|
||||
@@ -32,12 +39,12 @@ class CycleViewSet(BaseViewSet):
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
|
||||
@@ -55,6 +62,12 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
@@ -62,14 +75,13 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("cycle")
|
||||
.select_related("issue")
|
||||
.select_related("issue__state")
|
||||
.select_related("issue", "issue__state", "issue__project")
|
||||
.prefetch_related("issue__assignees", "issue__labels")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
|
||||
issues = request.data.get("issues", [])
|
||||
|
||||
if not len(issues):
|
||||
@@ -81,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
pk__in=issues, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# Get all CycleIssues already created
|
||||
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
||||
records_to_update = []
|
||||
update_cycle_issue_activity = []
|
||||
record_to_create = []
|
||||
|
||||
# Delete old records in order to maintain the database integrity
|
||||
CycleIssue.objects.filter(issue_id__in=issues).delete()
|
||||
for issue in issues:
|
||||
cycle_issue = [
|
||||
cycle_issue
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
# Update only when cycle changes
|
||||
if len(cycle_issue):
|
||||
if cycle_issue[0].cycle_id != cycle_id:
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_issue[0].cycle_id),
|
||||
"new_cycle_id": str(cycle_id),
|
||||
"issue_id": str(cycle_issue[0].issue_id),
|
||||
}
|
||||
)
|
||||
cycle_issue[0].cycle_id = cycle_id
|
||||
records_to_update.append(cycle_issue[0])
|
||||
else:
|
||||
record_to_create.append(
|
||||
CycleIssue(
|
||||
project_id=project_id,
|
||||
workspace=cycle.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
cycle=cycle,
|
||||
issue_id=issue,
|
||||
)
|
||||
)
|
||||
|
||||
CycleIssue.objects.bulk_create(
|
||||
[
|
||||
CycleIssue(
|
||||
project_id=project_id,
|
||||
workspace=cycle.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
cycle=cycle,
|
||||
issue=issue,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
record_to_create,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
CycleIssue.objects.bulk_update(
|
||||
records_to_update,
|
||||
["cycle"],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"requested_data": json.dumps({"cycles_list": issues}),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
return Response(
|
||||
CycleIssueSerializer(self.get_queryset(), many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Cycle.DoesNotExist:
|
||||
return Response(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
from .github import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
# Python improts
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseViewSet
|
||||
from plane.db.models import (
|
||||
Integration,
|
||||
WorkspaceIntegration,
|
||||
Workspace,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
APIToken,
|
||||
)
|
||||
from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from plane.utils.integrations.github import (
|
||||
get_github_metadata,
|
||||
delete_github_installation,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationViewSet(BaseViewSet):
|
||||
serializer_class = IntegrationSerializer
|
||||
model = Integration
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
serializer = IntegrationSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
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, pk):
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
if integration.verified:
|
||||
return Response(
|
||||
{"error": "Verified integrations cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IntegrationSerializer(
|
||||
integration, data=request.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 Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Integration 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 WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceIntegrationSerializer
|
||||
model = WorkspaceIntegration
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("integration")
|
||||
)
|
||||
|
||||
def create(self, request, slug, provider):
|
||||
try:
|
||||
installation_id = request.data.get("installation_id", None)
|
||||
|
||||
if not installation_id:
|
||||
return Response(
|
||||
{"error": "Installation ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
integration = Integration.objects.get(provider=provider)
|
||||
config = {}
|
||||
if provider == "github":
|
||||
metadata = get_github_metadata(installation_id)
|
||||
config = {"installation_id": installation_id}
|
||||
|
||||
# Create a bot user
|
||||
bot_user = User.objects.create(
|
||||
email=f"{uuid.uuid4().hex}@plane.so",
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
is_bot=True,
|
||||
first_name=integration.title,
|
||||
avatar=integration.avatar_url
|
||||
if integration.avatar_url is not None
|
||||
else "",
|
||||
)
|
||||
|
||||
# Create an API Token for the bot user
|
||||
api_token = APIToken.objects.create(
|
||||
user=bot_user,
|
||||
user_type=1, # bot user
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.create(
|
||||
workspace=workspace,
|
||||
integration=integration,
|
||||
actor=bot_user,
|
||||
api_token=api_token,
|
||||
metadata=metadata,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Add bot user as a member of workspace
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace=workspace_integration.workspace,
|
||||
member=bot_user,
|
||||
role=20,
|
||||
)
|
||||
return Response(
|
||||
WorkspaceIntegrationSerializer(workspace_integration).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "Integration is already active in the workspace"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except (Workspace.DoesNotExist, Integration.DoesNotExist) as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Workspace or Integration not 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 destroy(self, request, slug, pk):
|
||||
try:
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
|
||||
if workspace_integration.integration.provider == "github":
|
||||
installation_id = workspace_integration.config.get(
|
||||
"installation_id", False
|
||||
)
|
||||
if installation_id:
|
||||
delete_github_installation(installation_id=installation_id)
|
||||
|
||||
workspace_integration.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration 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,
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepositorySync,
|
||||
GithubRepository,
|
||||
WorkspaceIntegration,
|
||||
ProjectMember,
|
||||
Label,
|
||||
GithubCommentSync,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repos
|
||||
|
||||
|
||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, workspace_integration_id):
|
||||
try:
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
repositories_url = workspace_integration.metadata["repositories_url"]
|
||||
repositories = get_github_repos(access_tokens_url, repositories_url)
|
||||
return Response(repositories, status=status.HTTP_200_OK)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration Does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubRepositorySyncSerializer
|
||||
model = GithubRepositorySync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
try:
|
||||
name = request.data.get("name", False)
|
||||
url = request.data.get("url", False)
|
||||
config = request.data.get("config", {})
|
||||
repository_id = request.data.get("repository_id", False)
|
||||
owner = request.data.get("owner", False)
|
||||
|
||||
if not name or not url or not repository_id or not owner:
|
||||
return Response(
|
||||
{"error": "Name, url, repository_id and owner are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace integration
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=workspace_integration_id
|
||||
)
|
||||
|
||||
# Delete the old repository object
|
||||
GithubRepositorySync.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).delete()
|
||||
GithubRepository.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).delete()
|
||||
# Project member delete
|
||||
ProjectMember.objects.filter(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
).delete()
|
||||
|
||||
# Create repository
|
||||
repo = GithubRepository.objects.create(
|
||||
name=name,
|
||||
url=url,
|
||||
config=config,
|
||||
repository_id=repository_id,
|
||||
owner=owner,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Create a Label for github
|
||||
label = Label.objects.filter(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if label is None:
|
||||
label = Label.objects.create(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
description="Label to sync Plane issues with GitHub issues",
|
||||
color="#003773",
|
||||
)
|
||||
|
||||
# Create repo sync
|
||||
repo_sync = GithubRepositorySync.objects.create(
|
||||
repository=repo,
|
||||
workspace_integration=workspace_integration,
|
||||
actor=workspace_integration.actor,
|
||||
credentials=request.data.get("credentials", {}),
|
||||
project_id=project_id,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Add bot as a member in the project
|
||||
_ = ProjectMember.objects.create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
)
|
||||
|
||||
# Return Response
|
||||
return Response(
|
||||
GithubRepositorySyncSerializer(repo_sync).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration 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 GithubIssueSyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubIssueSyncSerializer
|
||||
model = GithubIssueSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
repository_sync_id=self.kwargs.get("repo_sync_id"),
|
||||
)
|
||||
|
||||
|
||||
class GithubCommentSyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubCommentSyncSerializer
|
||||
model = GithubCommentSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_sync_id=self.kwargs.get("issue_sync_id"),
|
||||
)
|
||||
@@ -3,16 +3,13 @@ import json
|
||||
from itertools import groupby, chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
@@ -25,6 +22,7 @@ from plane.api.serializers import (
|
||||
LabelSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -42,7 +40,10 @@ from plane.db.models import (
|
||||
IssueBlocker,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@@ -73,14 +74,13 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def perform_update(self, serializer):
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = Issue.objects.filter(pk=self.kwargs.get("pk", None)).first()
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.send)(
|
||||
"issue-activites",
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
@@ -93,10 +93,37 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"issue_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
},
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
@@ -126,49 +153,50 @@ class IssueViewSet(BaseViewSet):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
queryset=ModuleIssue.objects.select_related(
|
||||
"module", "issue"
|
||||
).prefetch_related("module__members"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("issue").select_related(
|
||||
"created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def grouper(self, issue, group_by):
|
||||
group_by = issue.get(group_by, "")
|
||||
|
||||
if isinstance(group_by, list):
|
||||
if len(group_by):
|
||||
return group_by[0]
|
||||
else:
|
||||
return ""
|
||||
|
||||
else:
|
||||
return group_by
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
issue_queryset = self.get_queryset()
|
||||
# Issue State groups
|
||||
type = request.GET.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
group = ["backlog"]
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
# TODO: Move this group by from ittertools to ORM for better performance - nk
|
||||
if group_by:
|
||||
issue_dict = dict()
|
||||
|
||||
issues = IssueSerializer(issue_queryset, many=True).data
|
||||
|
||||
for key, value in groupby(
|
||||
issues, lambda issue: self.grouper(issue, group_by)
|
||||
):
|
||||
issue_dict[str(key)] = list(value)
|
||||
|
||||
return Response(issue_dict, status=status.HTTP_200_OK)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True).data,
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.order_by(request.GET.get("order_by", "created_at"))
|
||||
.filter(state__group__in=group)
|
||||
)
|
||||
|
||||
issues = IssueSerializer(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:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -186,15 +214,18 @@ class IssueViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
|
||||
# Track the issue
|
||||
IssueActivity.objects.create(
|
||||
issue_id=serializer.data["id"],
|
||||
project_id=project_id,
|
||||
workspace_id=serializer["workspace"],
|
||||
comment=f"{request.user.email} created the issue",
|
||||
verb="created",
|
||||
actor=request.user,
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.created",
|
||||
"requested_data": json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
"actor_id": str(request.user.id),
|
||||
"issue_id": str(serializer.data.get("id", None)),
|
||||
"project_id": str(project_id),
|
||||
"current_instance": None,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -207,8 +238,56 @@ class IssueViewSet(BaseViewSet):
|
||||
class UserWorkSpaceIssues(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = Issue.objects.filter(
|
||||
assignees__in=[request.user], workspace__slug=slug
|
||||
issues = (
|
||||
Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocked_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"blocked_by", "block"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocker_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"block", "blocked_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle",
|
||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related(
|
||||
"issue"
|
||||
).select_related("created_by"),
|
||||
)
|
||||
)
|
||||
)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -221,7 +300,6 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
|
||||
|
||||
class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
@@ -242,7 +320,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueActivityEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
@@ -251,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
try:
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
~Q(field="comment"),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor")
|
||||
).order_by("created_by")
|
||||
issue_comments = (
|
||||
@@ -277,7 +357,6 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
permission_classes = [
|
||||
@@ -295,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
actor=self.request.user if self.request.user is not None else None,
|
||||
)
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.created",
|
||||
"requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id")),
|
||||
"project_id": str(self.kwargs.get("project_id")),
|
||||
"current_instance": None,
|
||||
},
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"comment_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
@@ -380,7 +513,6 @@ class IssuePropertyViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
issue_property, created = IssueProperty.objects.get_or_create(
|
||||
user=request.user,
|
||||
project_id=project_id,
|
||||
@@ -407,7 +539,6 @@ class IssuePropertyViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class LabelViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = LabelSerializer
|
||||
model = Label
|
||||
permission_classes = [
|
||||
@@ -434,14 +565,12 @@ class LabelViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
if not len(issue_ids):
|
||||
@@ -468,3 +597,96 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
sub_issues = (
|
||||
Issue.objects.filter(
|
||||
parent_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocked_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"blocked_by", "block"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocker_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"block", "blocked_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle",
|
||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
serializer = IssueSerializer(sub_issues, 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,
|
||||
)
|
||||
|
||||
# Assign multiple sub issues
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
parent_issue = Issue.objects.get(pk=issue_id)
|
||||
sub_issue_ids = request.data.get("sub_issue_ids", [])
|
||||
|
||||
if not len(sub_issue_ids):
|
||||
return Response(
|
||||
{"error": "Sub Issue IDs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sub_issues = 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)
|
||||
|
||||
return Response(
|
||||
IssueFlatSerializer(updated_sub_issues, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"Parent 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,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django Imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Prefetch, F, OuterRef, Func
|
||||
from django.core import serializers
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -15,11 +19,17 @@ from plane.api.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Module, ModuleIssue, Project, Issue, ModuleLink
|
||||
from plane.db.models import (
|
||||
Module,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
Issue,
|
||||
ModuleLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
|
||||
model = Module
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -45,13 +55,15 @@ class ModuleViewSet(BaseViewSet):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
queryset=ModuleIssue.objects.select_related(
|
||||
"module", "issue", "issue__state", "issue__project"
|
||||
).prefetch_related("issue__assignees", "issue__labels"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
queryset=ModuleLink.objects.select_related("module"),
|
||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -87,7 +99,6 @@ class ModuleViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
|
||||
@@ -110,6 +121,12 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(module_id=self.kwargs.get("module_id"))
|
||||
@@ -117,7 +134,9 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("module")
|
||||
.select_related("issue")
|
||||
.select_related("issue", "issue__state", "issue__project")
|
||||
.prefetch_related("issue__assignees", "issue__labels")
|
||||
.prefetch_related("module__members")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -132,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug, project_id=project_id, pk=module_id
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
pk__in=issues, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
|
||||
|
||||
# Delete old records in order to maintain the database integrity
|
||||
ModuleIssue.objects.filter(issue_id__in=issues).delete()
|
||||
update_module_issue_activity = []
|
||||
records_to_update = []
|
||||
record_to_create = []
|
||||
|
||||
for issue in issues:
|
||||
module_issue = [
|
||||
module_issue
|
||||
for module_issue in module_issues
|
||||
if str(module_issue.issue_id) in issues
|
||||
]
|
||||
|
||||
if len(module_issue):
|
||||
if module_issue[0].module_id != module_id:
|
||||
update_module_issue_activity.append(
|
||||
{
|
||||
"old_module_id": str(module_issue[0].module_id),
|
||||
"new_module_id": str(module_id),
|
||||
"issue_id": str(module_issue[0].issue_id),
|
||||
}
|
||||
)
|
||||
module_issue[0].module_id = module_id
|
||||
records_to_update.append(module_issue[0])
|
||||
else:
|
||||
record_to_create.append(
|
||||
ModuleIssue(
|
||||
module=module,
|
||||
issue_id=issue,
|
||||
project_id=project_id,
|
||||
workspace=module.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
module=module,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace=module.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
record_to_create,
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
ModuleIssue.objects.bulk_update(
|
||||
records_to_update,
|
||||
["module"],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"requested_data": json.dumps({"modules_list": issues}),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
ModuleIssueSerializer(self.get_queryset(), many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Module.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -164,4 +231,4 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -34,7 +34,6 @@ def get_tokens_for_user(user):
|
||||
|
||||
def validate_google_token(token, client_id):
|
||||
try:
|
||||
|
||||
id_info = id_token.verify_oauth2_token(
|
||||
token, google_auth_request.Request(), client_id
|
||||
)
|
||||
@@ -106,9 +105,19 @@ def get_user_data(access_token: str) -> dict:
|
||||
|
||||
resp = requests.get(url=url, headers=headers)
|
||||
|
||||
userData = resp.json()
|
||||
user_data = resp.json()
|
||||
|
||||
return userData
|
||||
response = requests.get(
|
||||
url="https://api.github.com/user/emails", headers=headers
|
||||
).json()
|
||||
|
||||
[
|
||||
user_data.update({"email": item.get("email")})
|
||||
for item in response
|
||||
if item.get("primary") is True
|
||||
]
|
||||
|
||||
return user_data
|
||||
|
||||
|
||||
class OauthEndpoint(BaseAPIView):
|
||||
@@ -116,7 +125,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
|
||||
medium = request.data.get("medium", False)
|
||||
id_token = request.data.get("credential", False)
|
||||
client_id = request.data.get("clientId", False)
|
||||
@@ -138,7 +146,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
|
||||
email = data.get("email", None)
|
||||
if email == None:
|
||||
|
||||
return Response(
|
||||
{
|
||||
"error": "Something went wrong. Please try again later or contact the support team."
|
||||
@@ -153,7 +160,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
mobile_number = uuid.uuid4().hex
|
||||
email_verified = True
|
||||
else:
|
||||
|
||||
return Response(
|
||||
{
|
||||
"error": "Something went wrong. Please try again later or contact the support team."
|
||||
@@ -223,8 +229,8 @@ class OauthEndpoint(BaseAPIView):
|
||||
username=username,
|
||||
email=email,
|
||||
mobile_number=mobile_number,
|
||||
first_name=data["first_name"],
|
||||
last_name=data["last_name"],
|
||||
first_name=data.get("first_name", ""),
|
||||
last_name=data.get("last_name", ""),
|
||||
is_email_verified=email_verified,
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
@@ -7,48 +7,11 @@ from sentry_sdk import capture_exception
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
UserSerializer,
|
||||
WorkSpaceSerializer,
|
||||
)
|
||||
|
||||
from plane.api.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import User, Workspace
|
||||
|
||||
|
||||
class PeopleEndpoint(BaseAPIView):
|
||||
|
||||
filterset_fields = ("date_joined",)
|
||||
|
||||
search_fields = (
|
||||
"^first_name",
|
||||
"^last_name",
|
||||
"^email",
|
||||
"^username",
|
||||
)
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
users = User.objects.all().order_by("-date_joined")
|
||||
if (
|
||||
request.GET.get("search", None) is not None
|
||||
and len(request.GET.get("search")) < 3
|
||||
):
|
||||
return Response(
|
||||
{"message": "Search term must be at least 3 characters long"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=self.filter_queryset(users),
|
||||
on_results=lambda data: UserSerializer(data, many=True).data,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"message": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
serializer_class = UserSerializer
|
||||
model = User
|
||||
|
||||
@@ -67,13 +67,14 @@ class ProjectViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(Q(project_projectmember__member=self.request.user) | Q(network=2))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = ProjectSerializer(
|
||||
@@ -94,6 +95,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
"color": "#5e6ad2",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
@@ -130,6 +132,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
sequence=state["sequence"],
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
@@ -186,7 +189,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except (Project.DoesNotExist or Workspace.DoesNotExist) as e:
|
||||
except Project.DoesNotExist or Workspace.DoesNotExist as e:
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
@@ -204,14 +207,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class InviteProjectEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
email = request.data.get("email", False)
|
||||
role = request.data.get("role", False)
|
||||
|
||||
@@ -285,7 +286,6 @@ class InviteProjectEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class UserProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
@@ -294,12 +294,11 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace")
|
||||
.select_related("workspace", "workspace__owner", "project")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
|
||||
invitations = request.data.get("invitations")
|
||||
project_invitations = ProjectMemberInvite.objects.filter(
|
||||
pk__in=invitations, accepted=True
|
||||
@@ -329,7 +328,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ProjectMemberSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
@@ -349,18 +347,17 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.select_related("project")
|
||||
.select_related("member")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
|
||||
class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
member_id = request.data.get("member_id", False)
|
||||
role = request.data.get("role", False)
|
||||
|
||||
@@ -409,13 +406,11 @@ class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
|
||||
try:
|
||||
team_members = TeamMember.objects.filter(
|
||||
workspace__slug=slug, team__in=request.data.get("teams", [])
|
||||
@@ -464,7 +459,6 @@ class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class ProjectMemberInvitationsViewset(BaseViewSet):
|
||||
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
@@ -481,11 +475,11 @@ class ProjectMemberInvitationsViewset(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.select_related("project")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberInviteDetailViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
@@ -496,18 +490,21 @@ class ProjectMemberInviteDetailViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(super().get_queryset().select_related("project"))
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("project")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
|
||||
class ProjectIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
|
||||
name = request.GET.get("name", "").strip().upper()
|
||||
|
||||
if name == "":
|
||||
@@ -532,7 +529,6 @@ class ProjectIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
def delete(self, request, slug):
|
||||
try:
|
||||
|
||||
name = request.data.get("name", "").strip().upper()
|
||||
|
||||
if name == "":
|
||||
@@ -607,7 +603,6 @@ class ProjectJoinEndpoint(BaseAPIView):
|
||||
class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
@@ -646,7 +641,6 @@ class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
project_member = ProjectMember.objects.get(
|
||||
project_id=project_id, workspace__slug=slug, member=request.user
|
||||
)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# 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
|
||||
from plane.api.serializers import StateSerializer
|
||||
@@ -6,7 +15,6 @@ from plane.db.models import State
|
||||
|
||||
|
||||
class StateViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = StateSerializer
|
||||
model = State
|
||||
permission_classes = [
|
||||
@@ -27,3 +35,38 @@ class StateViewSet(BaseViewSet):
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
state_dict = dict()
|
||||
states = StateSerializer(self.get_queryset(), many=True).data
|
||||
|
||||
for key, value in groupby(
|
||||
sorted(states, key=lambda state: state["group"]),
|
||||
lambda state: state.get("group"),
|
||||
):
|
||||
state_dict[str(key)] = list(value)
|
||||
|
||||
return Response(state_dict, 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, pk):
|
||||
try:
|
||||
state = State.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if state.default:
|
||||
return Response(
|
||||
{"error": "Default state cannot be deleted"}, status=False
|
||||
)
|
||||
|
||||
state.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except State.DoesNotExist:
|
||||
return Response({"error": "State does not exists"}, status=status.HTTP_404)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.db.models import CharField, Count
|
||||
from django.db.models import CharField, Count, OuterRef, Func, F
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
# Third party modules
|
||||
@@ -111,6 +111,14 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
|
||||
member_count = (
|
||||
WorkspaceMember.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())
|
||||
@@ -119,7 +127,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
workspace_member__member=request.user,
|
||||
)
|
||||
.select_related("owner")
|
||||
).annotate(total_members=Count("workspace_member"))
|
||||
).annotate(total_members=member_count)
|
||||
|
||||
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -176,7 +184,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=workspace.id,
|
||||
member__email__in=[email.get("email") for email in emails],
|
||||
)
|
||||
).select_related("member", "workspace", "workspace__owner")
|
||||
|
||||
if len(workspace_members):
|
||||
return Response(
|
||||
@@ -339,7 +347,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
|
||||
@@ -353,7 +361,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -524,7 +532,7 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace_id=last_workspace_id, member=request.user
|
||||
).select_related("workspace", "project", "member")
|
||||
).select_related("workspace", "project", "member", "workspace__owner")
|
||||
|
||||
project_member_serializer = ProjectMemberSerializer(
|
||||
project_member, many=True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from channels.routing import ProtocolTypeRouter, ChannelNameRouter
|
||||
from channels.routing import ProtocolTypeRouter
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
@@ -10,15 +10,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
# Initialize Django ASGI application early to ensure the AppRegistry
|
||||
# is populated before importing code that may import ORM models.
|
||||
|
||||
from plane.api.consumers import IssueConsumer
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": get_asgi_application(),
|
||||
"channel": ChannelNameRouter(
|
||||
{
|
||||
"issue-activites": IssueConsumer.as_asgi(),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
# Python imports
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from django_rq import job
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Issue,
|
||||
Project,
|
||||
Label,
|
||||
IssueActivity,
|
||||
State,
|
||||
Cycle,
|
||||
Module,
|
||||
)
|
||||
from plane.api.serializers import IssueActivitySerializer
|
||||
|
||||
|
||||
# Track Chnages in name
|
||||
def track_name(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("name") != requested_data.get("name"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("name"),
|
||||
new_value=requested_data.get("name"),
|
||||
field="name",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in parent issue
|
||||
def track_parent(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("parent") != requested_data.get("parent"):
|
||||
if requested_data.get("parent") == None:
|
||||
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}",
|
||||
new_value=None,
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the parent issue to None",
|
||||
old_identifier=old_parent.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
|
||||
old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first()
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}"
|
||||
if old_parent is not None
|
||||
else None,
|
||||
new_value=f"{project.identifier}-{new_parent.sequence_id}",
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
|
||||
old_identifier=old_parent.id if old_parent is not None else None,
|
||||
new_identifier=new_parent.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in priority
|
||||
def track_priority(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("priority") != requested_data.get("priority"):
|
||||
if requested_data.get("priority") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=None,
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the priority to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track chnages in state of the issue
|
||||
def track_state(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("state") != requested_data.get("state"):
|
||||
new_state = State.objects.get(pk=requested_data.get("state", None))
|
||||
old_state = State.objects.get(pk=current_instance.get("state", None))
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=old_state.name,
|
||||
new_value=new_state.name,
|
||||
field="state",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the state to {new_state.name}",
|
||||
old_identifier=old_state.id,
|
||||
new_identifier=new_state.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track issue description
|
||||
def track_description(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue target date
|
||||
def track_target_date(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("target_date") != requested_data.get("target_date"):
|
||||
if requested_data.get("target_date") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("target_date"),
|
||||
new_value=requested_data.get("target_date"),
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the target date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("target_date"),
|
||||
new_value=requested_data.get("target_date"),
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue start date
|
||||
def track_start_date(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if current_instance.get("start_date") != requested_data.get("start_date"):
|
||||
if requested_data.get("start_date") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("start_date"),
|
||||
new_value=requested_data.get("start_date"),
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("start_date"),
|
||||
new_value=requested_data.get("start_date"),
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue labels
|
||||
def track_labels(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
# Label Addition
|
||||
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
|
||||
for label in requested_data.get("labels_list"):
|
||||
if label not in current_instance.get("labels"):
|
||||
label = Label.objects.get(pk=label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=label.name,
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added label {label.name}",
|
||||
new_identifier=label.id,
|
||||
old_identifier=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Label Removal
|
||||
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
|
||||
for label in current_instance.get("labels"):
|
||||
if label not in requested_data.get("labels_list"):
|
||||
label = Label.objects.get(pk=label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=label.name,
|
||||
new_value="",
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed label {label.name}",
|
||||
old_identifier=label.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue assignees
|
||||
def track_assignees(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
# Assignee Addition
|
||||
if len(requested_data.get("assignees_list")) > len(
|
||||
current_instance.get("assignees")
|
||||
):
|
||||
for assignee in requested_data.get("assignees_list"):
|
||||
if assignee not in current_instance.get("assignees"):
|
||||
assignee = User.objects.get(pk=assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=assignee.email,
|
||||
field="assignees",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added assignee {assignee.email}",
|
||||
new_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Assignee Removal
|
||||
if len(requested_data.get("assignees_list")) < len(
|
||||
current_instance.get("assignees")
|
||||
):
|
||||
for assignee in current_instance.get("assignees"):
|
||||
if assignee not in requested_data.get("assignees_list"):
|
||||
assignee = User.objects.get(pk=assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=assignee.email,
|
||||
new_value="",
|
||||
field="assignee",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed assignee {assignee.email}",
|
||||
old_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in blocking issues
|
||||
def track_blocks(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if len(requested_data.get("blocks_list")) > len(
|
||||
current_instance.get("blocked_issues")
|
||||
):
|
||||
for block in requested_data.get("blocks_list"):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
blocked
|
||||
for blocked in current_instance.get("blocked_issues")
|
||||
if blocked.get("block") == block
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
issue = Issue.objects.get(pk=block)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
new_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Blocked Issue Removal
|
||||
if len(requested_data.get("blocks_list")) < len(
|
||||
current_instance.get("blocked_issues")
|
||||
):
|
||||
for blocked in current_instance.get("blocked_issues"):
|
||||
if blocked.get("block") not in requested_data.get("blocks_list"):
|
||||
issue = Issue.objects.get(pk=blocked.get("block"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
old_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in blocked_by issues
|
||||
def track_blockings(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
if len(requested_data.get("blockers_list")) > len(
|
||||
current_instance.get("blocker_issues")
|
||||
):
|
||||
for block in requested_data.get("blockers_list"):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
blocked
|
||||
for blocked in current_instance.get("blocker_issues")
|
||||
if blocked.get("blocked_by") == block
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
issue = Issue.objects.get(pk=block)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
new_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
# Blocked Issue Removal
|
||||
if len(requested_data.get("blockers_list")) < len(
|
||||
current_instance.get("blocker_issues")
|
||||
):
|
||||
for blocked in current_instance.get("blocker_issues"):
|
||||
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
|
||||
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
old_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_cycles(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
# Updated Records:
|
||||
updated_records = current_instance.get("updated_cycle_issues", [])
|
||||
created_records = json.loads(current_instance.get("created_cycle_issues", []))
|
||||
|
||||
for updated_record in updated_records:
|
||||
old_cycle = Cycle.objects.filter(
|
||||
pk=updated_record.get("old_cycle_id", None)
|
||||
).first()
|
||||
new_cycle = Cycle.objects.filter(
|
||||
pk=updated_record.get("new_cycle_id", None)
|
||||
).first()
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=old_cycle.name,
|
||||
new_value=new_cycle.name,
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
)
|
||||
)
|
||||
|
||||
for created_record in created_records:
|
||||
cycle = Cycle.objects.filter(
|
||||
pk=created_record.get("fields").get("cycle")
|
||||
).first()
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=created_record.get("fields").get("issue"),
|
||||
actor=actor,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=cycle.name,
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added cycle {cycle.name}",
|
||||
new_identifier=cycle.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_modules(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
):
|
||||
# Updated Records:
|
||||
updated_records = current_instance.get("updated_module_issues", [])
|
||||
created_records = json.loads(current_instance.get("created_module_issues", []))
|
||||
|
||||
for updated_record in updated_records:
|
||||
old_module = Module.objects.filter(
|
||||
pk=updated_record.get("old_module_id", None)
|
||||
).first()
|
||||
new_module = Module.objects.filter(
|
||||
pk=updated_record.get("new_module_id", None)
|
||||
).first()
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=old_module.name,
|
||||
new_value=new_module.name,
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
|
||||
old_identifier=old_module.id,
|
||||
new_identifier=new_module.id,
|
||||
)
|
||||
)
|
||||
|
||||
for created_record in created_records:
|
||||
module = Module.objects.filter(
|
||||
pk=created_record.get("fields").get("module")
|
||||
).first()
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=created_record.get("fields").get("issue"),
|
||||
actor=actor,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} added module {module.name}",
|
||||
new_identifier=module.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": track_name,
|
||||
"parent": track_parent,
|
||||
"priority": track_priority,
|
||||
"state": track_state,
|
||||
"description": track_description,
|
||||
"target_date": track_target_date,
|
||||
"start_date": track_start_date,
|
||||
"labels_list": track_labels,
|
||||
"assignees_list": track_assignees,
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"cycles_list": track_cycles,
|
||||
"modules_list": track_modules,
|
||||
}
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
|
||||
|
||||
def create_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created a comment",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=requested_data.get("id"),
|
||||
issue_comment_id=requested_data.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if current_instance.get("comment_html") != requested_data.get("comment_html"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated a comment",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
old_value=current_instance.get("comment_html"),
|
||||
old_identifier=current_instance.get("id"),
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=current_instance.get("id"),
|
||||
issue_comment_id=current_instance.get("id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the issue",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the comment",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Receive message from room group
|
||||
@job("default")
|
||||
def issue_activity(event):
|
||||
try:
|
||||
issue_activities = []
|
||||
type = event.get("type")
|
||||
requested_data = json.loads(event.get("requested_data"))
|
||||
current_instance = (
|
||||
json.loads(event.get("current_instance"))
|
||||
if event.get("current_instance") is not None
|
||||
else None
|
||||
)
|
||||
issue_id = event.get("issue_id", None)
|
||||
actor_id = event.get("actor_id")
|
||||
project_id = event.get("project_id")
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
"issue.activity.updated": update_issue_activity,
|
||||
"issue.activity.deleted": delete_issue_activity,
|
||||
"comment.activity.created": create_comment_activity,
|
||||
"comment.activity.updated": update_comment_activity,
|
||||
"comment.activity.deleted": delete_comment_activity,
|
||||
}
|
||||
|
||||
func = ACTIVITY_MAPPER.get(type)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
|
||||
# Save all the values to database
|
||||
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
|
||||
# Post the updates to segway for integrations and webhooks
|
||||
if len(issue_activities_created):
|
||||
# Don't send activities if the actor is a bot
|
||||
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,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -1,52 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
from fieldsignals import post_save_changed
|
||||
|
||||
|
||||
class DbConfig(AppConfig):
|
||||
name = "plane.db"
|
||||
|
||||
# def ready(self):
|
||||
|
||||
# post_save_changed.connect(
|
||||
# self.model_activity,
|
||||
# sender=self.get_model("Issue"),
|
||||
# )
|
||||
|
||||
# def model_activity(self, sender, instance, changed_fields, **kwargs):
|
||||
|
||||
# verb = "created" if instance._state.adding else "changed"
|
||||
|
||||
# import inspect
|
||||
|
||||
# for frame_record in inspect.stack():
|
||||
# if frame_record[3] == "get_response":
|
||||
# request = frame_record[0].f_locals["request"]
|
||||
# REQUEST_METHOD = request.method
|
||||
|
||||
# if REQUEST_METHOD == "POST":
|
||||
|
||||
# self.get_model("IssueActivity").objects.create(
|
||||
# issue=instance, project=instance.project, actor=instance.created_by
|
||||
# )
|
||||
|
||||
# elif REQUEST_METHOD == "PATCH":
|
||||
|
||||
# try:
|
||||
# del changed_fields["updated_at"]
|
||||
# del changed_fields["updated_by"]
|
||||
# except KeyError as e:
|
||||
# pass
|
||||
|
||||
# for field_name, (old, new) in changed_fields.items():
|
||||
# field = field_name
|
||||
# old_value = old
|
||||
# new_value = new
|
||||
# self.get_model("IssueActivity").objects.create(
|
||||
# issue=instance,
|
||||
# verb=verb,
|
||||
# field=field,
|
||||
# old_value=old_value,
|
||||
# new_value=new_value,
|
||||
# project=instance.project,
|
||||
# actor=instance.updated_by,
|
||||
# )
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 3.2.16 on 2023-01-29 19:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.api_token
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0017_alter_workspace_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_bot',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='description',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='description_html',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='description_stripped',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='APIToken',
|
||||
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)),
|
||||
('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)),
|
||||
('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)),
|
||||
('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API Token',
|
||||
'verbose_name_plural': 'API Tokems',
|
||||
'db_table': 'api_tokens',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.16 on 2023-01-30 19:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0018_auto_20230130_0119'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='issueactivity',
|
||||
name='new_value',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='New Value'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issueactivity',
|
||||
name='old_value',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Old Value'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 3.2.16 on 2023-02-13 19:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0019_auto_20230131_0049'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='label',
|
||||
old_name='colour',
|
||||
new_name='color',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='workspace',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='db.workspace'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='completed_at',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='sort_order',
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='cycle_view',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='module_view',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='state',
|
||||
name='default',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='description',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='description_html',
|
||||
field=models.TextField(blank=True, default='<p></p>'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issuecomment',
|
||||
name='comment_html',
|
||||
field=models.TextField(blank=True, default='<p></p>'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issuecomment',
|
||||
name='comment_json',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
# Generated by Django 3.2.16 on 2023-02-22 19:34
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0020_auto_20230214_0118'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GithubRepository',
|
||||
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=500)),
|
||||
('url', models.URLField(null=True)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('repository_id', models.BigIntegerField()),
|
||||
('owner', models.CharField(max_length=500)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepository', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_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_githubrepository', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Repository',
|
||||
'verbose_name_plural': 'Repositories',
|
||||
'db_table': 'github_repositories',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
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)),
|
||||
('title', models.CharField(max_length=400)),
|
||||
('provider', models.CharField(max_length=400, unique=True)),
|
||||
('network', models.PositiveIntegerField(choices=[(1, 'Private'), (2, 'Public')], default=1)),
|
||||
('description', models.JSONField(default=dict)),
|
||||
('author', models.CharField(blank=True, max_length=400)),
|
||||
('webhook_url', models.TextField(blank=True)),
|
||||
('webhook_secret', models.TextField(blank=True)),
|
||||
('redirect_url', models.TextField(blank=True)),
|
||||
('metadata', models.JSONField(default=dict)),
|
||||
('verified', models.BooleanField(default=False)),
|
||||
('avatar_url', models.URLField(blank=True, null=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Integration',
|
||||
'verbose_name_plural': 'Integrations',
|
||||
'db_table': 'integrations',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issueactivity',
|
||||
name='issue',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activity', to='db.issue'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkspaceIntegration',
|
||||
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)),
|
||||
('metadata', models.JSONField(default=dict)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to=settings.AUTH_USER_MODEL)),
|
||||
('api_token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='db.apitoken')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrated_workspaces', to='db.integration')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_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_integrations', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Workspace Integration',
|
||||
'verbose_name_plural': 'Workspace Integrations',
|
||||
'db_table': 'workspace_integrations',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('workspace', 'integration')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueLink',
|
||||
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)),
|
||||
('title', models.CharField(max_length=255, null=True)),
|
||||
('url', models.URLField()),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_link', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelink', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_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_issuelink', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Issue Link',
|
||||
'verbose_name_plural': 'Issue Links',
|
||||
'db_table': 'issue_links',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GithubRepositorySync',
|
||||
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)),
|
||||
('credentials', models.JSONField(default=dict)),
|
||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_syncs', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('label', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repo_syncs', to='db.label')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepositorysync', to='db.project')),
|
||||
('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='syncs', to='db.githubrepository')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_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_githubrepositorysync', to='db.workspace')),
|
||||
('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.workspaceintegration')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Github Repository Sync',
|
||||
'verbose_name_plural': 'Github Repository Syncs',
|
||||
'db_table': 'github_repository_syncs',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('project', 'repository')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GithubIssueSync',
|
||||
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)),
|
||||
('repo_issue_id', models.BigIntegerField()),
|
||||
('github_issue_id', models.BigIntegerField()),
|
||||
('issue_url', models.URLField()),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubissuesync', to='db.project')),
|
||||
('repository_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_syncs', to='db.githubrepositorysync')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_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_githubissuesync', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Github Issue Sync',
|
||||
'verbose_name_plural': 'Github Issue Syncs',
|
||||
'db_table': 'github_issue_syncs',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('repository_sync', 'issue')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GithubCommentSync',
|
||||
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)),
|
||||
('repo_comment_id', models.BigIntegerField()),
|
||||
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.issuecomment')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.githubissuesync')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubcommentsync', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_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_githubcommentsync', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Github Comment Sync',
|
||||
'verbose_name_plural': 'Github Comment Syncs',
|
||||
'db_table': 'github_comment_syncs',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('issue_sync', 'comment')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,7 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ from .workspace import (
|
||||
TeamMember,
|
||||
)
|
||||
|
||||
from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier
|
||||
from .project import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectBaseModel,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
Issue,
|
||||
@@ -23,6 +29,7 @@ from .issue import (
|
||||
IssueAssignee,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -37,4 +44,15 @@ from .shortcut import Shortcut
|
||||
|
||||
from .view import View
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
|
||||
from .api_token import APIToken
|
||||
|
||||
from .integration import (
|
||||
WorkspaceIntegration,
|
||||
Integration,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubIssueSync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def generate_label_token():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def generate_token():
|
||||
return uuid4().hex + uuid4().hex
|
||||
|
||||
|
||||
class APIToken(BaseModel):
|
||||
token = models.CharField(max_length=255, unique=True, default=generate_token)
|
||||
label = models.CharField(max_length=255, default=generate_label_token)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bot_tokens",
|
||||
)
|
||||
user_type = models.PositiveSmallIntegerField(
|
||||
choices=((0, "Human"), (1, "Bot")), default=0
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "API Token"
|
||||
verbose_name_plural = "API Tokems"
|
||||
db_table = "api_tokens"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user.name)
|
||||
@@ -0,0 +1,2 @@
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
|
||||
@@ -0,0 +1,68 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class Integration(AuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
title = models.CharField(max_length=400)
|
||||
provider = models.CharField(max_length=400, unique=True)
|
||||
network = models.PositiveIntegerField(
|
||||
default=1, choices=((1, "Private"), (2, "Public"))
|
||||
)
|
||||
description = models.JSONField(default=dict)
|
||||
author = models.CharField(max_length=400, blank=True)
|
||||
webhook_url = models.TextField(blank=True)
|
||||
webhook_secret = models.TextField(blank=True)
|
||||
redirect_url = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
verified = models.BooleanField(default=False)
|
||||
avatar_url = models.URLField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return provider of the integration"""
|
||||
return f"{self.provider}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Integration"
|
||||
verbose_name_plural = "Integrations"
|
||||
db_table = "integrations"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceIntegration(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE
|
||||
)
|
||||
# Bot user
|
||||
actor = models.ForeignKey(
|
||||
"db.User", related_name="integrations", on_delete=models.CASCADE
|
||||
)
|
||||
integration = models.ForeignKey(
|
||||
"db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE
|
||||
)
|
||||
api_token = models.ForeignKey(
|
||||
"db.APIToken", related_name="integrations", on_delete=models.CASCADE
|
||||
)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
config = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the integration and workspace"""
|
||||
return f"{self.workspace.name} <{self.integration.provider}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "integration"]
|
||||
verbose_name = "Workspace Integration"
|
||||
verbose_name_plural = "Workspace Integrations"
|
||||
db_table = "workspace_integrations"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class GithubRepository(ProjectBaseModel):
|
||||
name = models.CharField(max_length=500)
|
||||
url = models.URLField(null=True)
|
||||
config = models.JSONField(default=dict)
|
||||
repository_id = models.BigIntegerField()
|
||||
owner = models.CharField(max_length=500)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo name"""
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Repository"
|
||||
verbose_name_plural = "Repositories"
|
||||
db_table = "github_repositories"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubRepositorySync(ProjectBaseModel):
|
||||
repository = models.OneToOneField(
|
||||
"db.GithubRepository", on_delete=models.CASCADE, related_name="syncs"
|
||||
)
|
||||
credentials = models.JSONField(default=dict)
|
||||
# Bot user
|
||||
actor = models.ForeignKey(
|
||||
"db.User", related_name="user_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
workspace_integration = models.ForeignKey(
|
||||
"db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
label = models.ForeignKey(
|
||||
"db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo sync"""
|
||||
return f"{self.repository.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "repository"]
|
||||
verbose_name = "Github Repository Sync"
|
||||
verbose_name_plural = "Github Repository Syncs"
|
||||
db_table = "github_repository_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubIssueSync(ProjectBaseModel):
|
||||
repo_issue_id = models.BigIntegerField()
|
||||
github_issue_id = models.BigIntegerField()
|
||||
issue_url = models.URLField(blank=False)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", related_name="github_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
repository_sync = models.ForeignKey(
|
||||
"db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.repository.name}-{self.project.name}-{self.issue.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["repository_sync", "issue"]
|
||||
verbose_name = "Github Issue Sync"
|
||||
verbose_name_plural = "Github Issue Syncs"
|
||||
db_table = "github_issue_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubCommentSync(ProjectBaseModel):
|
||||
repo_comment_id = models.BigIntegerField()
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
issue_sync = models.ForeignKey(
|
||||
"db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.comment.id}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue_sync", "comment"]
|
||||
verbose_name = "Github Comment Sync"
|
||||
verbose_name_plural = "Github Comment Syncs"
|
||||
db_table = "github_comment_syncs"
|
||||
ordering = ("-created_at",)
|
||||
@@ -4,11 +4,13 @@ from django.db import models
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
# TODO: Handle identifiers for Bulk Inserts - nk
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
@@ -32,9 +34,9 @@ class Issue(ProjectBaseModel):
|
||||
related_name="state_issue",
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
description = models.JSONField(blank=True)
|
||||
description_html = models.TextField(blank=True)
|
||||
description_stripped = models.TextField(blank=True)
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
@@ -56,6 +58,8 @@ class Issue(ProjectBaseModel):
|
||||
labels = models.ManyToManyField(
|
||||
"db.Label", blank=True, related_name="labels", through="IssueLabel"
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
@@ -65,6 +69,36 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This means that the model isn't saved to the database yet
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
project=self.project, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
self.state = State.objects.filter(project=self.project).first()
|
||||
else:
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
# Get the completed states of the project
|
||||
completed_states = State.objects.filter(
|
||||
group="completed", project=self.project
|
||||
).values_list("pk", flat=True)
|
||||
# Check if the current issue state and completed state id are same
|
||||
if self.state.id in completed_states:
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
@@ -75,19 +109,18 @@ class Issue(ProjectBaseModel):
|
||||
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
|
||||
if last_id is not None:
|
||||
self.sequence_id = last_id + 1
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
self.state, created = State.objects.get_or_create(
|
||||
project=self.project, name="Backlog"
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
largest_sort_order = Issue.objects.filter(
|
||||
project=self.project, state=self.state
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
strip_tags(self.description_html) if self.description_html != "" else ""
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
@@ -135,20 +168,33 @@ class IssueAssignee(ProjectBaseModel):
|
||||
return f"{self.issue.name} {self.assignee.email}"
|
||||
|
||||
|
||||
class IssueLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
url = models.URLField()
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Link"
|
||||
verbose_name_plural = "Issue Links"
|
||||
db_table = "issue_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.url}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_activity"
|
||||
Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
|
||||
)
|
||||
verb = models.CharField(max_length=255, verbose_name="Action", default="created")
|
||||
field = models.CharField(
|
||||
max_length=255, verbose_name="Field Name", blank=True, null=True
|
||||
)
|
||||
old_value = models.CharField(
|
||||
max_length=255, verbose_name="Old Value", blank=True, null=True
|
||||
)
|
||||
new_value = models.CharField(
|
||||
max_length=255, verbose_name="New Value", blank=True, null=True
|
||||
)
|
||||
old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
|
||||
new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
|
||||
|
||||
comment = models.TextField(verbose_name="Comment", blank=True)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
@@ -198,8 +244,8 @@ class TimelineIssue(ProjectBaseModel):
|
||||
|
||||
class IssueComment(ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, null=True)
|
||||
comment_html = models.TextField(blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
|
||||
# System can also create comment
|
||||
@@ -211,10 +257,11 @@ class IssueComment(ProjectBaseModel):
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
|
||||
self.comment_stripped = (
|
||||
strip_tags(self.comment_html) if self.comment_html != "" else ""
|
||||
)
|
||||
return super(IssueComment, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Comment"
|
||||
verbose_name_plural = "Issue Comments"
|
||||
@@ -247,7 +294,6 @@ class IssueProperty(ProjectBaseModel):
|
||||
|
||||
|
||||
class Label(ProjectBaseModel):
|
||||
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
@@ -257,7 +303,7 @@ class Label(ProjectBaseModel):
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
colour = models.CharField(max_length=255, blank=True)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Label"
|
||||
@@ -270,7 +316,6 @@ class Label(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueLabel(ProjectBaseModel):
|
||||
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="label_issue"
|
||||
)
|
||||
@@ -289,7 +334,6 @@ class IssueLabel(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueSequence(ProjectBaseModel):
|
||||
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True
|
||||
)
|
||||
@@ -306,7 +350,6 @@ class IssueSequence(ProjectBaseModel):
|
||||
# TODO: Find a better method to save the model
|
||||
@receiver(post_save, sender=Issue)
|
||||
def create_issue_sequence(sender, instance, created, **kwargs):
|
||||
|
||||
if created:
|
||||
IssueSequence.objects.create(
|
||||
issue=instance, sequence=instance.sequence_id, project=instance.project
|
||||
|
||||
@@ -29,7 +29,6 @@ def get_default_props():
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
|
||||
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
|
||||
name = models.CharField(max_length=255, verbose_name="Project Name")
|
||||
description = models.TextField(verbose_name="Project Description", blank=True)
|
||||
@@ -63,6 +62,8 @@ class Project(BaseModel):
|
||||
blank=True,
|
||||
)
|
||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
@@ -82,7 +83,6 @@ class Project(BaseModel):
|
||||
|
||||
|
||||
class ProjectBaseModel(BaseModel):
|
||||
|
||||
project = models.ForeignKey(
|
||||
Project, on_delete=models.CASCADE, related_name="project_%(class)s"
|
||||
)
|
||||
@@ -117,7 +117,6 @@ class ProjectMemberInvite(ProjectBaseModel):
|
||||
|
||||
|
||||
class ProjectMember(ProjectBaseModel):
|
||||
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -141,9 +140,9 @@ class ProjectMember(ProjectBaseModel):
|
||||
"""Return members of the project"""
|
||||
return f"{self.member.email} <{self.project.name}>"
|
||||
|
||||
|
||||
# TODO: Remove workspace relation later
|
||||
class ProjectIdentifier(AuditModel):
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", models.CASCADE, related_name="project_identifiers", null=True
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ class State(ProjectBaseModel):
|
||||
default="backlog",
|
||||
max_length=20,
|
||||
)
|
||||
default = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the state"""
|
||||
@@ -37,4 +38,13 @@ class State(ProjectBaseModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = State.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sequence = last_id + 15000
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@@ -68,6 +68,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
last_workspace_id = models.UUIDField(null=True)
|
||||
my_issues_prop = models.JSONField(null=True)
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
|
||||
@@ -101,7 +102,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_email(sender, instance, created, **kwargs):
|
||||
try:
|
||||
if created:
|
||||
if created and not instance.is_bot:
|
||||
first_name = instance.first_name.capitalize()
|
||||
to_email = instance.email
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
@@ -34,9 +35,7 @@ INSTALLED_APPS = [
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"fieldsignals",
|
||||
"django_rq",
|
||||
"channels",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
@@ -24,6 +25,10 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
|
||||
if DOCKERIZED:
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
@@ -41,15 +46,16 @@ INTERNAL_IPS = ("127.0.0.1",)
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
)
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
)
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
@@ -64,13 +70,11 @@ RQ_QUEUES = {
|
||||
},
|
||||
}
|
||||
|
||||
WEB_URL = "http://localhost:3000"
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(REDIS_HOST, REDIS_PORT)],
|
||||
},
|
||||
},
|
||||
}
|
||||
if DOCKERIZED:
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Production settings and globals."""
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
from redis.asyncio.connection import Connection, RedisSSLContext
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
@@ -36,6 +33,10 @@ CORS_ORIGIN_WHITELIST = [
|
||||
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
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
|
||||
@@ -51,99 +52,110 @@ CORS_ALLOW_ALL_ORIGINS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
)
|
||||
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)
|
||||
):
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION")
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
# AWS Settings End
|
||||
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
|
||||
# AWS Settings End
|
||||
else:
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
@@ -158,7 +170,6 @@ ALLOWED_HOSTS = [
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
@@ -168,16 +179,27 @@ CSRF_COOKIE_SECURE = True
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
if DOCKERIZED:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
@@ -186,64 +208,6 @@ RQ_QUEUES = {
|
||||
}
|
||||
|
||||
|
||||
class CustomSSLConnection(Connection):
|
||||
def __init__(
|
||||
self,
|
||||
ssl_context: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.ssl_context = RedisSSLContext(ssl_context)
|
||||
|
||||
|
||||
class RedisSSLContext:
|
||||
__slots__ = ("context",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ssl_context,
|
||||
):
|
||||
self.context = ssl_context
|
||||
|
||||
def get(self):
|
||||
return self.context
|
||||
|
||||
|
||||
url = urlparse(os.environ.get("REDIS_URL"))
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment
|
||||
|
||||
if not DOCKERIZED:
|
||||
|
||||
ssl_context = ssl.SSLContext()
|
||||
ssl_context.check_hostname = False
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [
|
||||
{
|
||||
"host": url.hostname,
|
||||
"port": url.port,
|
||||
"username": url.username,
|
||||
"password": url.password,
|
||||
"connection_class": CustomSSLConnection,
|
||||
"ssl_context": ssl_context,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
else:
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(os.environ.get("REDIS_URL"))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Production settings and globals."""
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
from redis.asyncio.connection import Connection, RedisSSLContext
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
@@ -14,7 +11,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = False
|
||||
DEBUG = True
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
@@ -186,52 +183,7 @@ RQ_QUEUES = {
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSSLConnection(Connection):
|
||||
def __init__(
|
||||
self,
|
||||
ssl_context: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.ssl_context = RedisSSLContext(ssl_context)
|
||||
|
||||
class RedisSSLContext:
|
||||
__slots__ = (
|
||||
"context",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ssl_context,
|
||||
):
|
||||
self.context = ssl_context
|
||||
|
||||
def get(self):
|
||||
return self.context
|
||||
|
||||
|
||||
url = urlparse(os.environ.get("REDIS_URL"))
|
||||
|
||||
ssl_context = ssl.SSLContext()
|
||||
ssl_context.check_hostname = False
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
'hosts': [
|
||||
{
|
||||
'host': url.hostname,
|
||||
'port': url.port,
|
||||
'username': url.username,
|
||||
'password': url.password,
|
||||
'connection_class': CustomSSLConnection,
|
||||
'ssl_context': ssl_context,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
def group_results(results_data, group_by):
|
||||
"""
|
||||
Utility function to group data into a given attribute.
|
||||
Function can group attributes of string and list type.
|
||||
"""
|
||||
response_dict = dict()
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = value.get(group_by, None)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in response_dict:
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
response_dict[str(attrib)] = []
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in response_dict:
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
response_dict[str(None)] = []
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
if str(group_attribute) in response_dict:
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
|
||||
return response_dict
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import jwt
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
def get_jwt_token():
|
||||
app_id = os.environ.get("GITHUB_APP_ID", "")
|
||||
secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8")
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
due_date = datetime.now() + timedelta(minutes=10)
|
||||
expiry = int(due_date.timestamp())
|
||||
payload = {
|
||||
"iss": app_id,
|
||||
"sub": app_id,
|
||||
"exp": expiry,
|
||||
"iat": current_timestamp,
|
||||
"aud": "https://github.com/login/oauth/access_token",
|
||||
}
|
||||
|
||||
priv_rsakey = load_pem_private_key(secret, None, default_backend())
|
||||
token = jwt.encode(payload, priv_rsakey, algorithm="RS256")
|
||||
return token
|
||||
|
||||
|
||||
def get_github_metadata(installation_id):
|
||||
token = get_jwt_token()
|
||||
|
||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(url, headers=headers).json()
|
||||
return response
|
||||
|
||||
|
||||
def get_github_repos(access_tokens_url, repositories_url):
|
||||
token = get_jwt_token()
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
|
||||
oauth_response = requests.post(
|
||||
access_tokens_url,
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
oauth_token = oauth_response.get("token")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + oauth_token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(
|
||||
repositories_url,
|
||||
headers=headers,
|
||||
).json()
|
||||
return response
|
||||
|
||||
|
||||
def delete_github_installation(installation_id):
|
||||
token = get_jwt_token()
|
||||
|
||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.delete(url, headers=headers)
|
||||
return response
|
||||
@@ -1,31 +1,29 @@
|
||||
# base requirements
|
||||
|
||||
Django==3.2.16
|
||||
Django==3.2.18
|
||||
django-braces==1.15.0
|
||||
django-taggit==2.1.0
|
||||
psycopg2==2.9.3
|
||||
django-oauth-toolkit==2.0.0
|
||||
mistune==2.0.3
|
||||
djangorestframework==3.13.1
|
||||
redis==4.2.2
|
||||
django-nested-admin==3.4.0
|
||||
django-cors-headers==3.11.0
|
||||
whitenoise==6.0.0
|
||||
django-allauth==0.50.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
django-oauth-toolkit==2.2.0
|
||||
mistune==2.0.4
|
||||
djangorestframework==3.14.0
|
||||
redis==4.4.2
|
||||
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==21.1
|
||||
jsonmodels==2.5.0
|
||||
djangorestframework-simplejwt==5.1.0
|
||||
sentry-sdk==1.5.12
|
||||
django-s3-storage==0.13.6
|
||||
django-filter==22.1
|
||||
jsonmodels==2.6.0
|
||||
djangorestframework-simplejwt==5.2.2
|
||||
sentry-sdk==1.14.0
|
||||
django-s3-storage==0.13.11
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
django-fieldsignals==0.7.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.9.1
|
||||
google-api-python-client==2.55.0
|
||||
django-rq==2.5.1
|
||||
google-auth==2.16.0
|
||||
google-api-python-client==2.75.0
|
||||
django-rq==2.6.0
|
||||
django-redis==5.2.0
|
||||
channels==4.0.0
|
||||
channels-redis==4.0.0
|
||||
uvicorn==0.20.0
|
||||
uvicorn==0.20.0
|
||||
channels==4.0.0
|
||||
@@ -1,3 +1,3 @@
|
||||
-r base.txt
|
||||
|
||||
django-debug-toolbar==3.2.4
|
||||
django-debug-toolbar==3.8.1
|
||||
@@ -1,12 +1,12 @@
|
||||
-r base.txt
|
||||
|
||||
dj-database-url==0.5.0
|
||||
dj-database-url==1.2.0
|
||||
gunicorn==20.1.0
|
||||
whitenoise==6.0.0
|
||||
django-storages==1.12.3
|
||||
whitenoise==6.3.0
|
||||
django-storages==1.13.2
|
||||
boto==2.49.0
|
||||
django-anymail==8.5
|
||||
twilio==7.8.2
|
||||
django-debug-toolbar==3.2.4
|
||||
django-anymail==9.0
|
||||
twilio==7.16.2
|
||||
django-debug-toolbar==3.8.1
|
||||
gevent==22.10.2
|
||||
psycogreen==1.0.2
|
||||
@@ -1 +1 @@
|
||||
python-3.11.1
|
||||
python-3.11.2
|
||||
@@ -17,7 +17,7 @@
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
<h1 id="site-name">{% trans 'plane Admin' %} </h1>
|
||||
<h1 id="site-name">{% trans 'Plane Django Admin' %} </h1>
|
||||
|
||||
|
||||
{% endblock %}{% block nav-global %}{% endblock %}
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
"website": "https://plane.so/",
|
||||
"success_url": "/",
|
||||
"stack": "heroku-22",
|
||||
"keywords": ["plane", "project management", "django", "next"],
|
||||
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
|
||||
"keywords": [
|
||||
"plane",
|
||||
"project management",
|
||||
"django",
|
||||
"next"
|
||||
],
|
||||
"addons": [
|
||||
"heroku-postgresql:mini",
|
||||
"heroku-redis:mini"
|
||||
],
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-python.git"
|
||||
@@ -74,4 +82,4 @@
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->"
|
||||
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
|
||||
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3000
|
||||
CMD ["yarn","dev"]
|
||||
+8
-26
@@ -4,33 +4,14 @@ RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add curl
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
COPY ./apps ./apps
|
||||
COPY ./package.json ./package.json
|
||||
COPY ./.eslintrc.json ./.eslintrc.json
|
||||
COPY ./turbo.json ./turbo.json
|
||||
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
RUN pnpm add -g turbo
|
||||
RUN turbo prune --scope=app --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add curl
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
@@ -39,14 +20,14 @@ WORKDIR /app
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN pnpm install
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN pnpm turbo run build --filter=app...
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
@@ -62,8 +43,9 @@ COPY --from=installer /app/apps/app/package.json .
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
CMD node apps/app/server.js
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { Button, Input } from "components/ui";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
// icons
|
||||
|
||||
// types
|
||||
type EmailCodeFormValues = {
|
||||
email: string;
|
||||
key?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeResent, setCodeResent] = useState(false);
|
||||
const [isCodeResending, setIsCodeResending] = useState(false);
|
||||
const [errorResendingCode, setErrorResendingCode] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailCodeFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
key: "",
|
||||
token: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const isResendDisabled =
|
||||
resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
|
||||
|
||||
const onSubmit = async ({ email }: EmailCodeFormValues) => {
|
||||
setErrorResendingCode(false);
|
||||
await authenticationService
|
||||
.emailCode({ email })
|
||||
.then((res) => {
|
||||
setValue("key", res.key);
|
||||
setCodeSent(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrorResendingCode(true);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: err?.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignin = async (formData: EmailCodeFormValues) => {
|
||||
await authenticationService
|
||||
.magicSignIn(formData)
|
||||
.then((response) => {
|
||||
onSuccess(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const emailOld = getValues("email");
|
||||
|
||||
useEffect(() => {
|
||||
setErrorResendingCode(false);
|
||||
}, [emailOld]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5 space-y-5">
|
||||
{(codeSent || codeResent) && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
{codeResent
|
||||
? "Please check your mail for new code."
|
||||
: "Please check your mail for code."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{codeSent && (
|
||||
<div>
|
||||
<Input
|
||||
id="token"
|
||||
type="token"
|
||||
name="token"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Code is required",
|
||||
}}
|
||||
error={errors.token}
|
||||
placeholder="Enter code"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-xs mt-5 w-full flex justify-end outline-none ${
|
||||
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
onSubmit({ email: getValues("email") }).then(() => {
|
||||
setCodeResent(true);
|
||||
setIsCodeResending(false);
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
{resendCodeTimer > 0 ? (
|
||||
<p className="text-right">
|
||||
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
|
||||
</p>
|
||||
) : isCodeResending ? (
|
||||
"Sending code..."
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
"Resend code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{codeSent ? (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send code"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+19
-17
@@ -1,29 +1,28 @@
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "ui";
|
||||
import authenticationService from "lib/services/authentication.service";
|
||||
import { Button, Input } from "components/ui";
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// types
|
||||
type SignIn = {
|
||||
type EmailPasswordFormValues = {
|
||||
email: string;
|
||||
password?: string;
|
||||
medium?: string;
|
||||
};
|
||||
|
||||
const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, dirtyFields, isValid, isDirty },
|
||||
} = useForm<SignIn>({
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailPasswordFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
@@ -33,19 +32,24 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = (formData: SignIn) => {
|
||||
const onSubmit = (formData: EmailPasswordFormValues) => {
|
||||
authenticationService
|
||||
.emailLogin(formData)
|
||||
.then(async (response) => {
|
||||
await onSuccess(response);
|
||||
.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", err);
|
||||
setError(key as keyof SignIn, {
|
||||
setError(key as keyof EmailPasswordFormValues, {
|
||||
type: "manual",
|
||||
message: Array.isArray(err) ? err.join(", ") : err,
|
||||
});
|
||||
@@ -85,8 +89,8 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="text-sm ml-auto">
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="ml-auto text-sm">
|
||||
<Link href={"/forgot-password"}>
|
||||
<a className="font-medium text-theme hover:text-indigo-500">Forgot your password?</a>
|
||||
</Link>
|
||||
@@ -105,5 +109,3 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailPasswordForm;
|
||||
@@ -0,0 +1,46 @@
|
||||
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} />
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="mt-6 flex w-full flex-col items-stretch gap-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded border border-gray-300 px-3 py-2 text-sm duration-300 hover:bg-gray-100"
|
||||
onClick={() => setUseCode((prev) => !prev)}
|
||||
>
|
||||
<KeyIcon className="h-[25px] w-[25px]" />
|
||||
<span className="w-full text-center font-medium">
|
||||
{useCode ? "Continue with Password" : "Continue with Code"}
|
||||
</span>
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState, FC } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// images
|
||||
import githubImage from "/public/logos/github.png";
|
||||
|
||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
||||
|
||||
export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
// router
|
||||
const {
|
||||
query: { code },
|
||||
} = useRouter();
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
handleSignIn(code.toString());
|
||||
}
|
||||
}, [code, handleSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/signin` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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 rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
|
||||
<Image
|
||||
src={githubImage}
|
||||
height={25}
|
||||
width={25}
|
||||
className="flex-shrink-0"
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="w-full text-center font-medium">Continue with GitHub</span>
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
+6
-5
@@ -4,12 +4,13 @@ import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
text?: string;
|
||||
onSuccess?: (res: any) => void;
|
||||
onFailure?: (res: any) => void;
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
@@ -17,7 +18,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
window?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
callback: props.onSuccess as any,
|
||||
callback: handleSignIn,
|
||||
});
|
||||
window?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
@@ -32,7 +33,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
);
|
||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
setGsiScriptLoaded(true);
|
||||
}, [props.onSuccess, gsiScriptLoaded]);
|
||||
}, [handleSignIn, gsiScriptLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window?.google?.accounts?.id) {
|
||||
@@ -46,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div className="w-full" id="googleSignInButton" ref={googleSignInButton}></div>
|
||||
<div className="w-full" id="googleSignInButton" ref={googleSignInButton} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./google-login";
|
||||
export * from "./email-code-form";
|
||||
export * from "./email-password-form";
|
||||
export * from "./github-login-button";
|
||||
export * from "./email-signin-form";
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// icons
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
@@ -15,7 +15,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||
className="grid h-8 w-8 cursor-pointer place-items-center flex-shrink-0 rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
@@ -32,29 +32,27 @@ type BreadcrumbItemProps = {
|
||||
icon?: any;
|
||||
};
|
||||
|
||||
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => {
|
||||
return (
|
||||
<>
|
||||
{link ? (
|
||||
<Link href={link}>
|
||||
<a className="border-r-2 border-gray-300 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon ?? null}
|
||||
{title}
|
||||
</p>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="px-3 text-sm">
|
||||
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => (
|
||||
<>
|
||||
{link ? (
|
||||
<Link href={link}>
|
||||
<a className="border-r-2 border-gray-300 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
{icon ?? null}
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="px-3 text-sm max-w-64">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
<span className="break-all">{title}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
||||
|
||||
+84
-87
@@ -1,29 +1,26 @@
|
||||
// TODO: Refactor this component: into a different file, use this file to export the components
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
// next
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
// components
|
||||
import ShortcutsModal from "components/command-palette/shortcuts";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
|
||||
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
|
||||
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
|
||||
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { ShortcutsModal } from "components/command-palette";
|
||||
import { BulkDeleteIssuesModal } from "components/core";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateUpdateModuleModal } from "components/modules";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
import { Button } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
FolderIcon,
|
||||
@@ -31,12 +28,14 @@ import {
|
||||
ClipboardDocumentListIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// common
|
||||
import { classNames, copyTextToClipboard } from "constants/common";
|
||||
// fetch-keys
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
|
||||
const CommandPalette: React.FC = () => {
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@@ -75,7 +74,7 @@ const CommandPalette: React.FC = () => {
|
||||
name: "Add new issue...",
|
||||
icon: RectangleStackIcon,
|
||||
hide: !projectId,
|
||||
shortcut: "I",
|
||||
shortcut: "C",
|
||||
onClick: () => {
|
||||
setIsIssueModalOpen(true);
|
||||
},
|
||||
@@ -98,49 +97,56 @@ const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
|
||||
e.preventDefault();
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!(e.target instanceof HTMLTextAreaElement) &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target as Element).classList?.contains("remirror-editor")
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
if (!router.query.issueId) return;
|
||||
|
||||
if (!router.query.issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if (e.key.toLowerCase() === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "m") {
|
||||
e.preventDefault();
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (e.key === "Delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleCollapsed, setToastAlert, router]
|
||||
@@ -163,20 +169,17 @@ const CommandPalette: React.FC = () => {
|
||||
<>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={isCreateCycleModalOpen}
|
||||
setIsOpen={setIsCreateCycleModalOpen}
|
||||
projectId={projectId as string}
|
||||
handleClose={() => setIsCreateCycleModalOpen(false)}
|
||||
/>
|
||||
<CreateUpdateModuleModal
|
||||
isOpen={isCreateModuleModalOpen}
|
||||
setIsOpen={setIsCreateModuleModalOpen}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CreateUpdateIssuesModal
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalOpen}
|
||||
setIsOpen={setIsIssueModalOpen}
|
||||
projectId={projectId as string}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
/>
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
@@ -188,7 +191,7 @@ const CommandPalette: React.FC = () => {
|
||||
afterLeave={() => setQuery("")}
|
||||
appear
|
||||
>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleCommandPaletteClose}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleCommandPaletteClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -201,7 +204,7 @@ const CommandPalette: React.FC = () => {
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -228,7 +231,7 @@ const CommandPalette: React.FC = () => {
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
autoComplete="off"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,10 +258,9 @@ const CommandPalette: React.FC = () => {
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
@@ -307,24 +309,21 @@ const CommandPalette: React.FC = () => {
|
||||
onClick: action.onClick,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-default select-none items-center rounded-md px-3 py-2",
|
||||
`flex cursor-default select-none items-center rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<action.icon
|
||||
className={classNames(
|
||||
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
|
||||
className={`h-6 w-6 flex-none text-gray-900 text-opacity-40 ${
|
||||
active ? "text-opacity-100" : ""
|
||||
)}
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-3 flex-auto truncate">{action.name}</span>
|
||||
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
|
||||
<kbd className="font-sans">⌘</kbd>
|
||||
<kbd className="font-sans">{action.shortcut}</kbd>
|
||||
</span>
|
||||
</>
|
||||
@@ -365,5 +364,3 @@ const CommandPalette: React.FC = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandPalette;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./command-pallette";
|
||||
export * from "./shortcuts-modal";
|
||||
+61
-37
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
// ui
|
||||
import { Input } from "ui";
|
||||
import { Input } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +15,7 @@ const shortcuts = [
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ keys: "ctrl,/", description: "To open navigator" },
|
||||
{ keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
|
||||
{ keys: "↑", description: "Move up" },
|
||||
{ keys: "↓", description: "Move down" },
|
||||
{ keys: "←", description: "Move left" },
|
||||
@@ -27,32 +27,37 @@ const shortcuts = [
|
||||
{
|
||||
title: "Common",
|
||||
shortcuts: [
|
||||
{ keys: "ctrl,p", description: "To create project" },
|
||||
{ keys: "ctrl,i", description: "To create issue" },
|
||||
{ keys: "ctrl,q", description: "To create cycle" },
|
||||
{ keys: "ctrl,m", description: "To create module" },
|
||||
{ keys: "ctrl,d", description: "To bulk delete issues" },
|
||||
{ keys: "ctrl,h", description: "To open shortcuts guide" },
|
||||
{ keys: "P", description: "To create project" },
|
||||
{ keys: "C", description: "To create issue" },
|
||||
{ keys: "Q", description: "To create cycle" },
|
||||
{ keys: "M", description: "To create module" },
|
||||
{ keys: "Delete", description: "To bulk delete issues" },
|
||||
{ keys: "H", description: "To open shortcuts guide" },
|
||||
{
|
||||
keys: "ctrl,alt,c",
|
||||
keys: "Ctrl,/,Cmd,Alt,C",
|
||||
description: "To copy issue url when on issue detail page.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
|
||||
|
||||
const filteredShortcuts = shortcuts.filter((shortcut) =>
|
||||
shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === ""
|
||||
export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const filteredShortcuts = allShortcuts.filter((shortcut) =>
|
||||
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
|
||||
? true
|
||||
: false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setQuery("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setIsOpen}>
|
||||
<Dialog as="div" className="relative z-20" onClose={setIsOpen}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -65,7 +70,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
@@ -79,10 +84,10 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white p-5">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="flex flex-col gap-y-4 text-center sm:text-left w-full">
|
||||
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900 flex justify-between"
|
||||
className="flex justify-between text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<span>
|
||||
@@ -103,11 +108,43 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-3 w-full">
|
||||
{filteredShortcuts.length > 0 ? (
|
||||
filteredShortcuts.map(({ title, shortcuts }) => (
|
||||
<div key={title} className="w-full flex flex-col">
|
||||
<p className="font-medium mb-4">{title}</p>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
{query.trim().length > 0 ? (
|
||||
filteredShortcuts.length > 0 ? (
|
||||
filteredShortcuts.map((shortcut) => (
|
||||
<div key={shortcut.keys} className="flex w-full flex-col">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{shortcut.keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
No shortcuts found for{" "}
|
||||
<span className="font-semibold italic">
|
||||
{`"`}
|
||||
{query}
|
||||
{`"`}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
shortcuts.map(({ title, shortcuts }) => (
|
||||
<div key={title} className="flex w-full flex-col">
|
||||
<p className="mb-4 font-medium">{title}</p>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{shortcuts.map(({ keys, description }, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
@@ -115,7 +152,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<div className="flex items-center gap-x-1">
|
||||
{keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<kbd className="bg-gray-200 text-sm px-1 rounded">
|
||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
@@ -126,17 +163,6 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
No shortcuts found for{" "}
|
||||
<span className="font-semibold italic">
|
||||
{`"`}
|
||||
{query}
|
||||
{`"`}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,5 +176,3 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsModal;
|
||||
@@ -1,5 +0,0 @@
|
||||
const SingleBoard = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default SingleBoard;
|
||||
@@ -1,393 +0,0 @@
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react-beautiful-dnd
|
||||
import { DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/";
|
||||
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
import stateService from "lib/services/state.service";
|
||||
import projectService from "lib/services/project.service";
|
||||
// icons
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
import User from "public/user.png";
|
||||
// types
|
||||
import { IIssue, IssueResponse, IWorkspaceMember, Properties } from "types";
|
||||
// common
|
||||
import {
|
||||
addSpaceIfCamelCase,
|
||||
classNames,
|
||||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
snapshot?: DraggableStateSnapshot;
|
||||
assignees: {
|
||||
avatar: string | undefined;
|
||||
first_name: string | undefined;
|
||||
email: string | undefined;
|
||||
}[];
|
||||
people: IWorkspaceMember[] | undefined;
|
||||
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
|
||||
};
|
||||
|
||||
const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue,
|
||||
properties,
|
||||
snapshot,
|
||||
assignees,
|
||||
people,
|
||||
handleDeleteIssue,
|
||||
partialUpdateIssue,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR<IssueResponse>(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: states } = useSWR(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded border bg-white shadow-sm ${
|
||||
snapshot && snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="group/card relative select-none p-2">
|
||||
{handleDeleteIssue && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||
onClick={() => handleDeleteIssue(issue.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="mb-3 text-sm group-hover:text-theme"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{issue.name}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={issue.priority}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data }, issue.id);
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button
|
||||
className={`grid cursor-pointer place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(issue?.priority ?? "None")}
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize"
|
||||
)
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
{getPriorityIcon(priority)}
|
||||
{priority}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{properties.state && (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ state: data }, issue.id);
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button className="flex cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(issue.state_detail.name)}
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{states?.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"flex cursor-pointer select-none items-center gap-2 px-3 py-2"
|
||||
)
|
||||
}
|
||||
value={state.id}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(state.name)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
|
||||
<h5 className="font-medium mb-1">State</h5>
|
||||
<div>{issue.state_detail.name}</div>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<div
|
||||
className={`group flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
? "text-red-600"
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
|
||||
</div>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={issue.assignees}
|
||||
onChange={(data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
if (newData.includes(data)) {
|
||||
newData.splice(newData.indexOf(data), 1);
|
||||
} else {
|
||||
newData.push(data);
|
||||
}
|
||||
partialUpdateIssue({ assignees_list: newData }, issue.id);
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`relative z-[1] h-5 w-5 rounded-full ${
|
||||
index !== 0 ? "-ml-2.5" : ""
|
||||
}`}
|
||||
>
|
||||
{assignee.avatar && assignee.avatar !== "" ? (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
|
||||
<Image
|
||||
src={assignee.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={assignee?.first_name}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
|
||||
{assignee.first_name && assignee.first_name !== ""
|
||||
? assignee.first_name.charAt(0)
|
||||
: assignee?.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
|
||||
<Image
|
||||
src={User}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="No user"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"cursor-pointer select-none p-2"
|
||||
)
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-x-1 ${
|
||||
assignees.includes({
|
||||
avatar: person.member.avatar,
|
||||
first_name: person.member.first_name,
|
||||
email: person.member.email,
|
||||
})
|
||||
? "font-medium"
|
||||
: "font-normal"
|
||||
}`}
|
||||
>
|
||||
{person.member.avatar && person.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={person.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name.charAt(0)
|
||||
: person.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleBoardIssue;
|
||||
@@ -1,260 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// react hook form
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
import projectService from "lib/services/project.service";
|
||||
// hooks
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
import { LayerDiagonalIcon } from "ui/icons";
|
||||
|
||||
type FormInput = {
|
||||
issue_ids: string[];
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = router;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormInput>();
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleDelete: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!data.issue_ids || data.issue_ids.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
|
||||
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesServices
|
||||
.bulkDeleteIssues(workspaceSlug as string, projectId as string, data)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: res.message,
|
||||
});
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
count: (prevData?.results ?? []).filter(
|
||||
(p) => !data.issue_ids.some((id) => p.id === id)
|
||||
).length,
|
||||
results: (prevData?.results ?? []).filter(
|
||||
(p) => !data.issue_ids.some((id) => p.id === id)
|
||||
),
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<form>
|
||||
<Combobox>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to delete
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<FolderIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
|
||||
Delete selected issues
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkDeleteIssuesModal;
|
||||
@@ -1,175 +0,0 @@
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
// ui
|
||||
import { CustomMenu } from "ui";
|
||||
// icons
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse, Properties } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// common
|
||||
import {
|
||||
addSpaceIfCamelCase,
|
||||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
handleDeleteIssue: () => void;
|
||||
removeIssue: () => void;
|
||||
};
|
||||
|
||||
const SingleListIssue: React.FC<Props> = ({
|
||||
type,
|
||||
issue,
|
||||
properties,
|
||||
editIssue,
|
||||
handleDeleteIssue,
|
||||
removeIssue,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
let { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR<IssueResponse>(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
)}
|
||||
<span>{issue.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<div
|
||||
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{/* {getPriorityIcon(issue.priority ?? "")} */}
|
||||
{issue.priority ?? "None"}
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
|
||||
<div
|
||||
className={`capitalize ${
|
||||
issue.priority === "urgent"
|
||||
? "text-red-600"
|
||||
: issue.priority === "high"
|
||||
? "text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "text-green-500"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{issue.priority ?? "None"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{properties.state && (
|
||||
<div className="group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue?.state_detail?.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(issue?.state_detail.name)}
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium">State</h5>
|
||||
<div>{issue?.state_detail.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<div
|
||||
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
? "text-red-600"
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
|
||||
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
|
||||
<div>
|
||||
{issue.target_date &&
|
||||
(issue.target_date < new Date().toISOString()
|
||||
? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days`
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3
|
||||
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
|
||||
: "Due date")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{properties.sub_issue_count && projectId && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{type && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => editIssue()}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => removeIssue()}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue()}>
|
||||
Delete permanently
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleListIssue;
|
||||
@@ -0,0 +1,84 @@
|
||||
// hooks
|
||||
import useIssueView from "hooks/use-issue-view";
|
||||
// components
|
||||
import { SingleBoard } from "components/core/board-view/single-board";
|
||||
// types
|
||||
import { IIssue, IProjectMember, IState, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
type: "issue" | "cycle" | "module";
|
||||
issues: IIssue[];
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const AllBoards: React.FC<Props> = ({
|
||||
type,
|
||||
issues,
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
handleEditIssue,
|
||||
openIssuesListModal,
|
||||
handleDeleteIssue,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const stateId =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null;
|
||||
|
||||
const bgColor =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.color
|
||||
: "#000000";
|
||||
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
type={type}
|
||||
bgColor={bgColor}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
handleEditIssue={handleEditIssue}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DraggableProvided } from "react-beautiful-dnd";
|
||||
// icons
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf } from "types";
|
||||
type Props = {
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
groupTitle: string;
|
||||
bgColor?: string;
|
||||
addIssueToState: () => void;
|
||||
members: IProjectMember[] | undefined;
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BoardHeader: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
groupTitle,
|
||||
bgColor,
|
||||
addIssueToState,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
members,
|
||||
}) => {
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
||||
: null;
|
||||
|
||||
let assignees: any;
|
||||
if (selectedGroup === "assignees") {
|
||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
||||
assignees =
|
||||
assignees.length > 0
|
||||
? assignees
|
||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
||||
.join(", ")
|
||||
: "No assignee";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between p-3 pb-0 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `2px solid ${bgColor}`,
|
||||
backgroundColor: `${bgColor}20`,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
style={{
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{selectedGroup === "created_by"
|
||||
? createdBy
|
||||
: selectedGroup === "assignees"
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
</h2>
|
||||
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./all-boards";
|
||||
export * from "./board-header";
|
||||
export * from "./single-board";
|
||||
export * from "./single-issue";
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
// components
|
||||
import { BoardHeader, SingleBoardIssue } from "components/core";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
bgColor?: string;
|
||||
groupTitle: string;
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
addIssueToState: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SingleBoard: React.FC<Props> = ({
|
||||
type,
|
||||
bgColor,
|
||||
groupTitle,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
members,
|
||||
handleEditIssue,
|
||||
addIssueToState,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
? (bgColor = "#dc2626")
|
||||
: groupTitle === "medium"
|
||||
? (bgColor = "#f97316")
|
||||
: groupTitle === "low"
|
||||
? (bgColor = "#22c55e")
|
||||
: (bgColor = "#ff0000");
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
bgColor={bgColor}
|
||||
selectedGroup={selectedGroup}
|
||||
groupTitle={groupTitle}
|
||||
groupedByIssues={groupedByIssues}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
members={members}
|
||||
/>
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative mt-3 h-full px-3 pb-3 overflow-y-auto ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-0 left-0 h-full w-full bg-indigo-200 opacity-50 pointer-events-none z-[99999998]`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xs whitespace-nowrap bg-white p-2 rounded pointer-events-none z-[99999999]`}
|
||||
>
|
||||
This board is ordered by {orderBy}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{groupedByIssues[groupTitle].map((issue, index: number) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
key={index}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
type={type}
|
||||
issue={issue}
|
||||
selectedGroup={selectedGroup}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={() => {
|
||||
removeIssue && removeIssue(issue.bridge);
|
||||
}}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
display: orderBy === "sort_order" ? "inline" : "none",
|
||||
}}
|
||||
>
|
||||
{provided.placeholder}
|
||||
</span>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="mr-1 h-3 w-3" />
|
||||
Create
|
||||
</button>
|
||||
) : (
|
||||
<CustomMenu
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add issue
|
||||
</span>
|
||||
}
|
||||
className="mt-1"
|
||||
optionsPosition="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
||||
{openIssuesListModal && (
|
||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import {
|
||||
DraggableProvided,
|
||||
DraggableStateSnapshot,
|
||||
DraggingStyle,
|
||||
NotDraggingStyle,
|
||||
} from "react-beautiful-dnd";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
CycleIssueResponse,
|
||||
IIssue,
|
||||
ModuleIssueResponse,
|
||||
NestedKeyOf,
|
||||
Properties,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
issue: IIssue;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SingleBoardIssue: React.FC<Props> = ({
|
||||
type,
|
||||
provided,
|
||||
snapshot,
|
||||
issue,
|
||||
selectedGroup,
|
||||
properties,
|
||||
editIssue,
|
||||
removeIssue,
|
||||
handleDeleteIssue,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
||||
);
|
||||
|
||||
function getStyle(
|
||||
style: DraggingStyle | NotDraggingStyle | undefined,
|
||||
snapshot: DraggableStateSnapshot
|
||||
) {
|
||||
if (orderBy === "sort_order") return style;
|
||||
if (!snapshot.isDragging) return {};
|
||||
if (!snapshot.isDropAnimating) {
|
||||
return style;
|
||||
}
|
||||
|
||||
return {
|
||||
...style,
|
||||
transitionDuration: `0.001s`,
|
||||
};
|
||||
}
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(
|
||||
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
|
||||
).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||
}, [snapshot, handleTrashBox]);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded border bg-white shadow-sm mb-3 ${
|
||||
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getStyle(provided.draggableProps.style, snapshot)}
|
||||
>
|
||||
<div className="group/card relative select-none p-2">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="mb-3 text-sm group-hover:text-theme"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{issue.name}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="relative flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && selectedGroup !== "priority" && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.state && selectedGroup !== "state_detail.name" && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
tooltipPosition="left"
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook form
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
delete_issue_ids: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormInput>({
|
||||
defaultValues: {
|
||||
delete_issue_ids: [],
|
||||
},
|
||||
});
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues ?? []
|
||||
: issues?.filter(
|
||||
(issue) =>
|
||||
issue.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
`${issue.project_detail.identifier}-${issue.sequence_id}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
) ?? [];
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleDelete: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
|
||||
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesServices
|
||||
.bulkDeleteIssues(workspaceSlug as string, projectId as string, {
|
||||
issue_ids: data.delete_issue_ids,
|
||||
})
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: res.message,
|
||||
});
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<form>
|
||||
<Combobox
|
||||
onChange={(val: string) => {
|
||||
const selectedIssues = watch("delete_issue_ids");
|
||||
if (selectedIssues.includes(val))
|
||||
setValue(
|
||||
"delete_issue_ids",
|
||||
selectedIssues.filter((i) => i !== val)
|
||||
);
|
||||
else setValue("delete_issue_ids", [...selectedIssues, val]);
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to delete
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={watch("delete_issue_ids").includes(issue.id)}
|
||||
readOnly
|
||||
/>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit(handleDelete)}
|
||||
theme="danger"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Deleting..." : "Delete selected issues"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
+49
-72
@@ -1,27 +1,17 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// services
|
||||
import projectService from "lib/services/project.service";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
import useToast from "hooks/use-toast";
|
||||
// headless ui
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
import { LayerDiagonalIcon } from "ui/icons";
|
||||
|
||||
type FormInput = {
|
||||
issues: string[];
|
||||
@@ -30,30 +20,18 @@ type FormInput = {
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
type: string;
|
||||
issues: IIssue[];
|
||||
handleOnSubmit: (data: FormInput) => void;
|
||||
handleOnSubmit: any;
|
||||
};
|
||||
|
||||
const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose: onClose,
|
||||
issues,
|
||||
handleOnSubmit,
|
||||
type,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -73,18 +51,25 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<FormInput> = (data) => {
|
||||
const onSubmit: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!data.issues || data.issues.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleOnSubmit(data);
|
||||
await handleOnSubmit(data);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
@@ -118,7 +103,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<form>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -145,42 +130,39 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to add to {type}
|
||||
Select issues to add
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => {
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
@@ -188,10 +170,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
@@ -222,7 +201,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : `Add to ${type}`}
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,5 +214,3 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExistingIssuesListModal;
|
||||
+9
-9
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -8,11 +8,11 @@ import { useDropzone } from "react-dropzone";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileServices from "lib/services/file.service";
|
||||
import fileServices from "services/file.service";
|
||||
// icon
|
||||
import { UserCircleIcon } from "ui/icons";
|
||||
import { UserCircleIcon } from "components/icons";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
import { Button } from "components/ui";
|
||||
|
||||
type TImageUploadModalProps = {
|
||||
value?: string | null;
|
||||
@@ -70,7 +70,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -83,7 +83,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
@@ -103,13 +103,13 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative block w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
className={`relative block h-80 w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-gray-300 hover:border-gray-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{value && value !== "" ? (
|
||||
{image !== null || (value && value !== null && value !== "") ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -121,7 +121,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
<NextImage
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
src={image ? URL.createObjectURL(image) : value}
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
/>
|
||||
</>
|
||||
@@ -1 +1,11 @@
|
||||
export * from "./board-view";
|
||||
export * from "./list-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./image-upload-modal";
|
||||
export * from "./issues-view-filter";
|
||||
export * from "./issues-view";
|
||||
export * from "./link-modal";
|
||||
export * from "./not-authorized-view";
|
||||
export * from "./multi-level-select";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user