Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c632921f8 | ||
|
|
7c9035182b | ||
|
|
b5325f14aa | ||
|
|
4238f89cec | ||
|
|
a44cddb0fc | ||
|
|
83a0c8163f | ||
|
|
c8c195eab4 | ||
|
|
0acee1fe66 | ||
|
|
e5f6be54e0 | ||
|
|
15a846ce06 | ||
|
|
a01e241523 | ||
|
|
cba62f07c0 | ||
|
|
c1f8766571 | ||
|
|
af13a1b00a | ||
|
|
4439740768 | ||
|
|
98a223e5e1 | ||
|
|
e081395857 | ||
|
|
8b527f27d0 | ||
|
|
ae67dc6074 | ||
|
|
e1e9a5ed96 | ||
|
|
9ccc35d181 | ||
|
|
012486df11 | ||
|
|
1fed5f7846 | ||
|
|
8cbe6c4b36 | ||
|
|
d9642eee82 | ||
|
|
ab273f6be3 | ||
|
|
b1f26f322f | ||
|
|
2e4f936dfa | ||
|
|
ddeafc0695 | ||
|
|
406f02737f | ||
|
|
8e9afd459a | ||
|
|
186b5b5500 | ||
|
|
f2c8bdba34 | ||
|
|
e162c88f03 | ||
|
|
7f5fdb9589 | ||
|
|
e3a114cd69 | ||
|
|
ae1eb9527a | ||
|
|
6da4247400 | ||
|
|
4ce5a450d9 | ||
|
|
06618006c2 | ||
|
|
bb79c9de96 | ||
|
|
09cffd5498 | ||
|
|
5916d6e749 | ||
|
|
c3d520aefd | ||
|
|
d41250c1ce | ||
|
|
27626fb16f | ||
|
|
ab695a309f | ||
|
|
3427652c22 | ||
|
|
559b0cc9c8 | ||
|
|
c49b0d6151 | ||
|
|
d99f85ce05 | ||
|
|
e696a3741c | ||
|
|
d575e8ec6b | ||
|
|
c060f7db30 | ||
|
|
84a0f6f77e | ||
|
|
c6d78b5e6a | ||
|
|
8c707cc544 | ||
|
|
abe021071c | ||
|
|
8d6082183e | ||
|
|
7b52fb885d | ||
|
|
d28bc41fbd | ||
|
|
63075a6a0d | ||
|
|
9fdc56db0f | ||
|
|
290318603d | ||
|
|
dbbd9add99 | ||
|
|
37bb183bf0 | ||
|
|
512b8c104d | ||
|
|
bf865f399f | ||
|
|
6a78948113 | ||
|
|
6e9235e5fe | ||
|
|
f2a68874f1 | ||
|
|
9cb3794dde | ||
|
|
1329145173 | ||
|
|
44d49b5500 | ||
|
|
1a534a3c19 | ||
|
|
d7928f853d | ||
|
|
a0553722c9 | ||
|
|
34f4580b94 | ||
|
|
a1d7a4ea55 | ||
|
|
abaa65b4b7 | ||
|
|
fb165d080e | ||
|
|
083562b24c | ||
|
|
88d3fa549a | ||
|
|
c7deb00f2a | ||
|
|
4f2b106852 | ||
|
|
df96d40cfa | ||
|
|
4884ecd668 | ||
|
|
c16c5b1cb2 | ||
|
|
a69593a9e8 | ||
|
|
a1de3f581f | ||
|
|
443878994a | ||
|
|
86cb23777e | ||
|
|
93c105c495 | ||
|
|
b34cf0c471 | ||
|
|
fd96c54b43 | ||
|
|
993cf3faba | ||
|
|
1bf1b63fff | ||
|
|
336220bd98 | ||
|
|
5b0dc43bae | ||
|
|
e0bec31586 | ||
|
|
a2825208b8 | ||
|
|
c3387ba974 | ||
|
|
baa9c30449 | ||
|
|
849e2d658a | ||
|
|
c7f1090914 | ||
|
|
563bb12b9b | ||
|
|
e3d43298df | ||
|
|
b36565298f | ||
|
|
b26e8bd956 | ||
|
|
e5703dbe70 | ||
|
|
498d6d2b02 | ||
|
|
1d22817ede | ||
|
|
483c49d0ff | ||
|
|
0fa9451633 | ||
|
|
46237c5431 | ||
|
|
20e400487f | ||
|
|
99fb3c9bfe | ||
|
|
88200a93bf | ||
|
|
b2ad071608 | ||
|
|
21992f540f | ||
|
|
742731cbe6 | ||
|
|
c6878b9b0f | ||
|
|
887cac5612 | ||
|
|
982566b5b4 | ||
|
|
2dc5655886 | ||
|
|
160b4a4390 | ||
|
|
f187d9512a | ||
|
|
53d3ea1979 | ||
|
|
13d76d4325 | ||
|
|
25338cc804 | ||
|
|
eaa750b025 | ||
|
|
81d9c70026 | ||
|
|
93fb4fe1e9 | ||
|
|
b8e6d072cc | ||
|
|
1220cebe50 | ||
|
|
9464b5c00e | ||
|
|
3fae0f39c0 | ||
|
|
73a757e337 | ||
|
|
bb40b7feb5 | ||
|
|
3175ce9136 | ||
|
|
0b9b4bb289 | ||
|
|
f0f24b6fc4 | ||
|
|
429dffb055 | ||
|
|
6e5c85cd6e | ||
|
|
2adcb163fb | ||
|
|
028a350cd1 | ||
|
|
d021a5696a | ||
|
|
a5c18e37c1 | ||
|
|
8e611664a8 | ||
|
|
3480b450f2 | ||
|
|
a7d9591c44 | ||
|
|
1364c842e0 | ||
|
|
eb99b4adc9 | ||
|
|
6684dd4ab6 | ||
|
|
529ed4432c | ||
|
|
8c1ad69f0c | ||
|
|
c23de32b03 | ||
|
|
83ac1f4e4c | ||
|
|
cee9695a4a | ||
|
|
c9f866e538 | ||
|
|
7d96adcb70 | ||
|
|
c5b034385f | ||
|
|
ff7f31c35b | ||
|
|
7234d6f68b | ||
|
|
d8a5b8d848 | ||
|
|
5412e09701 | ||
|
|
7116acc331 | ||
|
|
ae26b17cab | ||
|
|
2ec8fbab34 | ||
|
|
213dc3f8e8 | ||
|
|
4b02886c40 | ||
|
|
8eddc4b304 | ||
|
|
169a60723b | ||
|
|
c80094581e | ||
|
|
d99f669b89 | ||
|
|
99dd1b9f0c | ||
|
|
48e77ea81b | ||
|
|
0be6738715 | ||
|
|
d041d8be6b | ||
|
|
e53847c59e | ||
|
|
16781a71fe | ||
|
|
fb4535b294 | ||
|
|
33a904bc3e | ||
|
|
0d264838a9 | ||
|
|
c638b6aba6 | ||
|
|
cb814dd68b | ||
|
|
e17c824119 | ||
|
|
68930c256f | ||
|
|
e59137f6f2 | ||
|
|
7f235739bd | ||
|
|
20162050c3 | ||
|
|
06ad0d0f7a | ||
|
|
cfd7e1d154 | ||
|
|
fdf7ea1f82 | ||
|
|
8f12d3d01b | ||
|
|
62dc6c2f3f | ||
|
|
6f03022f65 | ||
|
|
f2701a12ea | ||
|
|
9129a6cde2 | ||
|
|
2ba4594b29 | ||
|
|
165d16e32b | ||
|
|
73388195ef | ||
|
|
4dda4ec610 | ||
|
|
e68a5382f9 | ||
|
|
5b6caadd6f | ||
|
|
73a8bbb31f | ||
|
|
952d35dd79 | ||
|
|
170b3d6eec | ||
|
|
d04a422054 | ||
|
|
affc7655f7 | ||
|
|
50c78628b3 | ||
|
|
3c2f5d12ed | ||
|
|
9f04933957 | ||
|
|
8d37a3e58b | ||
|
|
4f61c5d552 | ||
|
|
7149d20601 | ||
|
|
d30a88832a | ||
|
|
ebce364104 | ||
|
|
c5206a7792 | ||
|
|
390b837561 | ||
|
|
fb1932e309 | ||
|
|
ac125965eb | ||
|
|
63a36fb25d | ||
|
|
2b280935a1 | ||
|
|
be5ef61428 | ||
|
|
1627a587ee | ||
|
|
682a1477fb | ||
|
|
ea87823478 | ||
|
|
b8c06b3121 | ||
|
|
ca2366aa9b | ||
|
|
acff6396f9 | ||
|
|
fa5c994ddc | ||
|
|
5f20e65ca6 | ||
|
|
792162ae66 | ||
|
|
e22f552ea0 | ||
|
|
396fbc4ebb | ||
|
|
5e5d1a4699 | ||
|
|
e2294f9105 | ||
|
|
f757d8232b | ||
|
|
6af54ebbe7 | ||
|
|
3913cf571f |
@@ -0,0 +1,20 @@
|
||||
# Replace with your instance Public IP
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
OPENAI_API_KEY=""
|
||||
GPT_ENGINE=""
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build Api Server Docker Image
|
||||
name: Build and Push Backend Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,11 +10,8 @@ on:
|
||||
|
||||
jobs:
|
||||
build_push_backend:
|
||||
name: Build Api Server Docker Image
|
||||
name: Build and Push Api Server Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -28,20 +25,33 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: "registry.hub.docker.com"
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
|
||||
id: ghmeta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: makeplane/plane-backend
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Github)
|
||||
id: dkrmeta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-backend
|
||||
|
||||
- name: Build Api Server
|
||||
- name: Build and Push to GitHub Container Registry
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./apiserver
|
||||
@@ -50,5 +60,18 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.ghmeta.outputs.tags }}
|
||||
labels: ${{ steps.ghmeta.outputs.labels }}
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.dkrmeta.outputs.tags }}
|
||||
labels: ${{ steps.dkrmeta.outputs.labels }}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build Frontend Docker Image
|
||||
name: Build and Push Frontend Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,9 +12,6 @@ jobs:
|
||||
build_push_frontend:
|
||||
name: Build Frontend Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -35,13 +32,26 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: "registry.hub.docker.com"
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
|
||||
id: ghmeta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: makeplane/plane-frontend
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Github)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-frontend
|
||||
|
||||
- name: Build Frontend Server
|
||||
- name: Build and Push to GitHub Container Registry
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
@@ -50,5 +60,18 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.ghmeta.outputs.tags }}
|
||||
labels: ${{ steps.ghmeta.outputs.labels }}
|
||||
|
||||
- name: Build and Push to Docker Container Registry
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/app/Dockerfile.web
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.dkrmeta.outputs.tags }}
|
||||
labels: ${{ steps.dkrmeta.outputs.labels }}
|
||||
|
||||
|
||||
+19
-1
@@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -16,7 +17,7 @@ FROM node:18-alpine AS installer
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
@@ -26,9 +27,16 @@ RUN yarn install
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
|
||||
|
||||
FROM python:3.11.1-alpine3.17 AS backend
|
||||
|
||||
@@ -108,6 +116,16 @@ COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
|
||||
|
||||
COPY nginx/supervisor.conf /code/supervisor.conf
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
|
||||
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
||||
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
||||
|
||||
|
||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||
@@ -58,11 +58,18 @@ cd plane
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
- Export Environment Variables
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
```
|
||||
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
docker-compose -f docker-compose-hub.yml up
|
||||
```
|
||||
|
||||
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
|
||||
@@ -128,7 +135,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
|
||||
|
||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
||||
|
||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/q9HKAdau).
|
||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
# Database
|
||||
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
# AWS
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
# FE
|
||||
WEB_URL="localhost/"
|
||||
# OAUTH
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
# Flags
|
||||
DISABLE_COLLECTSTATIC=1
|
||||
DOCKERIZED=1
|
||||
# GPT Envs
|
||||
OPENAI_API_KEY=0
|
||||
GPT_ENGINE=0
|
||||
@@ -3,7 +3,15 @@ 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, Project, ProjectMember, Label
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueComment,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
Label,
|
||||
Integration,
|
||||
)
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
@@ -174,3 +182,43 @@ def update_label_color():
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def create_slack_integration():
|
||||
try:
|
||||
_ = Integration.objects.create(provider="slack", network=2, title="Slack")
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_integration_verified():
|
||||
try:
|
||||
integrations = Integration.objects.all()
|
||||
updated_integrations = []
|
||||
for integration in integrations:
|
||||
integration.verified = True
|
||||
updated_integrations.append(integration)
|
||||
|
||||
Integration.objects.bulk_update(
|
||||
updated_integrations, ["verified"], batch_size=10
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_start_date():
|
||||
try:
|
||||
issues = Issue.objects.filter(state__group__in=["started", "completed"])
|
||||
updated_issues = []
|
||||
for issue in issues:
|
||||
issue.start_date = issue.created_at.date()
|
||||
updated_issues.append(issue)
|
||||
Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
@@ -62,10 +62,13 @@ from .integration import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
SlackProjectSyncSerializer,
|
||||
)
|
||||
|
||||
from .importer import ImporterSerializer
|
||||
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import AnalyticView
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class AnalyticViewSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = AnalyticView
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"query",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_dict", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
return AnalyticView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
@@ -19,10 +19,32 @@ class CycleSerializer(BaseSerializer):
|
||||
started_issues = serializers.IntegerField(read_only=True)
|
||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||
backlog_issues = serializers.IntegerField(read_only=True)
|
||||
assignees = serializers.SerializerMethodField(read_only=True)
|
||||
total_estimates = serializers.IntegerField(read_only=True)
|
||||
completed_estimates = serializers.IntegerField(read_only=True)
|
||||
started_estimates = serializers.IntegerField(read_only=True)
|
||||
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
def get_assignees(self, obj):
|
||||
members = [
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
for assignee in issue_cycle.issue.assignees.all()
|
||||
]
|
||||
# Use a set comprehension to return only the unique objects
|
||||
unique_objects = {frozenset(item.items()) for item in members}
|
||||
|
||||
# Convert the set back to a list of dictionaries
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
from .base import BaseSerializer
|
||||
|
||||
from plane.db.models import Estimate, EstimatePoint
|
||||
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
|
||||
|
||||
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
@@ -23,3 +27,18 @@ class EstimatePointSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class EstimateReadSerializer(BaseSerializer):
|
||||
points = EstimatePointSerializer(read_only=True, many=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"points",
|
||||
"name",
|
||||
"description",
|
||||
]
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import Importer
|
||||
|
||||
|
||||
class ImporterSerializer(BaseSerializer):
|
||||
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Importer
|
||||
|
||||
@@ -5,3 +5,4 @@ from .github import (
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from .slack import SlackProjectSyncSerializer
|
||||
@@ -0,0 +1,14 @@
|
||||
# Module imports
|
||||
from plane.api.serializers import BaseSerializer
|
||||
from plane.db.models import SlackProjectSync
|
||||
|
||||
|
||||
class SlackProjectSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = SlackProjectSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"workspace_integration",
|
||||
]
|
||||
@@ -82,6 +82,9 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
default_assignee = UserLiteSerializer(read_only=True)
|
||||
project_lead = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
||||
+92
-52
@@ -81,8 +81,6 @@ from plane.api.views import (
|
||||
StateViewSet,
|
||||
## End States
|
||||
# Estimates
|
||||
EstimateViewSet,
|
||||
EstimatePointViewSet,
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
## End Estimates
|
||||
@@ -133,6 +131,7 @@ from plane.api.views import (
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
SlackProjectSyncViewSet,
|
||||
## End Integrations
|
||||
# Importer
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
@@ -146,6 +145,16 @@ from plane.api.views import (
|
||||
# Gpt
|
||||
GPTIntegrationEndpoint,
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
# Analytics
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
## End Analytics
|
||||
)
|
||||
|
||||
|
||||
@@ -306,7 +315,6 @@ urlpatterns = [
|
||||
"workspaces/<str:slug>/members/<uuid:pk>/",
|
||||
WorkSpaceMemberViewSet.as_view(
|
||||
{
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
@@ -416,7 +424,6 @@ urlpatterns = [
|
||||
ProjectMemberViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
@@ -507,62 +514,34 @@ urlpatterns = [
|
||||
name="project-state",
|
||||
),
|
||||
# End States ##
|
||||
# States
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||
EstimateViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-estimates",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:pk>/",
|
||||
EstimateViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-estimates",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
|
||||
EstimatePointViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:pk>/",
|
||||
EstimatePointViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-estimates",
|
||||
),
|
||||
# Estimates
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-estimates/",
|
||||
ProjectEstimatePointEndpoint.as_view(),
|
||||
name="project-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/bulk-estimate-points/",
|
||||
BulkEstimatePointEndpoint.as_view(),
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||
BulkEstimatePointEndpoint.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
# End States ##
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/",
|
||||
BulkEstimatePointEndpoint.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
# End Estimates ##
|
||||
# Shortcuts
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
|
||||
@@ -1237,6 +1216,26 @@ urlpatterns = [
|
||||
),
|
||||
),
|
||||
## End Github Integrations
|
||||
# Slack Integration
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
|
||||
SlackProjectSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
|
||||
SlackProjectSyncViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
}
|
||||
),
|
||||
),
|
||||
## End Slack Integration
|
||||
## End Integrations
|
||||
# Importer
|
||||
path(
|
||||
@@ -1284,4 +1283,45 @@ urlpatterns = [
|
||||
name="importer",
|
||||
),
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
path(
|
||||
"release-notes/",
|
||||
ReleaseNotesEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
# Analytics
|
||||
path(
|
||||
"workspaces/<str:slug>/analytics/",
|
||||
AnalyticsEndpoint.as_view(),
|
||||
name="plane-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/analytic-view/",
|
||||
AnalyticViewViewset.as_view({"get": "list", "post": "create"}),
|
||||
name="analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
|
||||
AnalyticViewViewset.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/saved-analytic-view/<uuid:analytic_id>/",
|
||||
SavedAnalyticEndpoint.as_view(),
|
||||
name="saved-analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/export-analytics/",
|
||||
ExportAnalyticsEndpoint.as_view(),
|
||||
name="export-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/default-analytics/",
|
||||
DefaultAnalyticsEndpoint.as_view(),
|
||||
name="default-analytics",
|
||||
),
|
||||
## End Analytics
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ from .integration import (
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
SlackProjectSyncViewSet,
|
||||
)
|
||||
|
||||
from .importer import (
|
||||
@@ -133,8 +134,17 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
from .gpt import GPTIntegrationEndpoint
|
||||
|
||||
from .estimate import (
|
||||
EstimateViewSet,
|
||||
EstimatePointViewSet,
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
)
|
||||
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Sum,
|
||||
F,
|
||||
)
|
||||
from django.db.models.functions import ExtractMonth
|
||||
|
||||
# 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 BaseAPIView, BaseViewSet
|
||||
from plane.api.permissions import WorkSpaceAdminPermission
|
||||
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
|
||||
from plane.api.serializers import AnalyticViewSerializer
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class AnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
x_axis = request.GET.get("x_axis", False)
|
||||
y_axis = request.GET.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
|
||||
colors = dict()
|
||||
if x_axis in ["state__name", "state__group"] or segment in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
]:
|
||||
if x_axis in ["state__name", "state__group"]:
|
||||
key = "name" if x_axis == "state__name" else "group"
|
||||
else:
|
||||
key = "name" if segment == "state__name" else "group"
|
||||
|
||||
colors = (
|
||||
State.objects.filter(
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values(key, "color")
|
||||
if filters.get("project__in", False)
|
||||
else State.objects.filter(workspace__slug=slug).values(key, "color")
|
||||
)
|
||||
|
||||
if x_axis in ["labels__name"] or segment in ["labels__name"]:
|
||||
colors = (
|
||||
Label.objects.filter(
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values("name", "color")
|
||||
if filters.get("project__in", False)
|
||||
else Label.objects.filter(workspace__slug=slug).values(
|
||||
"name", "color"
|
||||
)
|
||||
)
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total": total_issues,
|
||||
"distribution": distribution,
|
||||
"extras": {"colors": colors, "assignee_details": assignee_details},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class AnalyticViewViewset(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = AnalyticView
|
||||
serializer_class = AnalyticViewSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
|
||||
)
|
||||
|
||||
|
||||
class SavedAnalyticEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, analytic_id):
|
||||
try:
|
||||
analytic_view = AnalyticView.objects.get(
|
||||
pk=analytic_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
filter = analytic_view.query
|
||||
queryset = Issue.objects.filter(**filter)
|
||||
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
total_issues = queryset.count()
|
||||
return Response(
|
||||
{"total": total_issues, "distribution": distribution},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except AnalyticView.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Analytic View Does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
x_axis = request.data.get("x_axis", False)
|
||||
y_axis = request.data.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
analytic_export_task.delay(
|
||||
email=request.user.email, data=request.data, slug=slug
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
|
||||
total_issues_classified = (
|
||||
queryset.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
open_issues = queryset.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"]
|
||||
).count()
|
||||
|
||||
open_issues_classified = (
|
||||
queryset.filter(state__group__in=["backlog", "unstarted", "started"])
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
issue_completed_month_wise = (
|
||||
queryset.filter(completed_at__isnull=False)
|
||||
.annotate(month=ExtractMonth("completed_at"))
|
||||
.values("month")
|
||||
.annotate(count=Count("*"))
|
||||
.order_by("month")
|
||||
)
|
||||
most_issue_created_user = (
|
||||
queryset.exclude(created_by=None)
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
most_issue_closed_user = (
|
||||
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
pending_issue_user = (
|
||||
queryset.filter(completed_at__isnull=True)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
open_estimate_sum = (
|
||||
queryset.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"]
|
||||
).aggregate(open_estimate_sum=Sum("estimate_point"))
|
||||
)["open_estimate_sum"]
|
||||
print(open_estimate_sum)
|
||||
|
||||
total_estimate_sum = queryset.aggregate(
|
||||
total_estimate_sum=Sum("estimate_point")
|
||||
)["total_estimate_sum"]
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total_issues": total_issues,
|
||||
"total_issues_classified": total_issues_classified,
|
||||
"open_issues": open_issues,
|
||||
"open_issues_classified": open_issues_classified,
|
||||
"issue_completed_month_wise": issue_completed_month_wise,
|
||||
"most_issue_created_user": most_issue_created_user,
|
||||
"most_issue_closed_user": most_issue_closed_user,
|
||||
"pending_issue_user": pending_issue_user,
|
||||
"open_estimate_sum": open_estimate_sum,
|
||||
"total_estimate_sum": total_estimate_sum,
|
||||
},
|
||||
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,
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
from sentry_sdk import capture_exception
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
# Module imports
|
||||
@@ -39,7 +39,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
|
||||
try:
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -3,7 +3,17 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Count,
|
||||
Prefetch,
|
||||
Sum,
|
||||
)
|
||||
from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -24,6 +34,7 @@ from plane.api.serializers import (
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
@@ -48,6 +59,28 @@ class CycleViewSet(BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"cycle_id": str(self.kwargs.get("pk")),
|
||||
"issues": [str(issue_id) for issue_id in cycle_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=None,
|
||||
)
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
@@ -96,6 +129,25 @@ class CycleViewSet(BaseViewSet):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
@@ -181,6 +233,22 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
cycle_id=self.kwargs.get("cycle_id"),
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"cycle_id": str(self.kwargs.get("cycle_id")),
|
||||
"issues": [str(instance.issue_id)],
|
||||
}
|
||||
),
|
||||
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=None,
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -286,9 +354,9 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
# 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 = []
|
||||
records_to_update = []
|
||||
|
||||
for issue in issues:
|
||||
cycle_issue = [
|
||||
@@ -333,7 +401,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
@@ -375,7 +443,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
try:
|
||||
start_date = request.data.get("start_date", False)
|
||||
end_date = request.data.get("end_date", False)
|
||||
|
||||
cycle_id = request.data.get("cycle_id")
|
||||
if not start_date or not end_date:
|
||||
return Response(
|
||||
{"error": "Start date and end date both are required"},
|
||||
@@ -383,12 +451,14 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
||||
| Q(start_date__gte=start_date, end_date__lte=end_date),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
Q(workspace__slug=slug)
|
||||
& Q(project_id=project_id)
|
||||
& (
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
||||
| Q(start_date__gte=start_date, end_date__lte=end_date)
|
||||
)
|
||||
).exclude(pk=cycle_id)
|
||||
|
||||
if cycles.exists():
|
||||
return Response(
|
||||
@@ -463,6 +533,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
@@ -507,6 +598,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
@@ -580,6 +692,27 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
@@ -655,6 +788,27 @@ class DraftCyclesEndpoint(BaseAPIView):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
|
||||
@@ -10,110 +10,11 @@ from sentry_sdk import capture_exception
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint
|
||||
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
|
||||
|
||||
|
||||
class EstimateViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
|
||||
class EstimatePointViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = EstimatePoint
|
||||
serializer_class = EstimatePointSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(estimate_id=self.kwargs.get("estimate_id"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
estimate_id=self.kwargs.get("estimate_id"),
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
serializer = EstimatePointSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(estimate_id=estimate_id, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The estimate point is already taken"},
|
||||
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,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id, pk):
|
||||
try:
|
||||
estimate_point = EstimatePoint.objects.get(
|
||||
pk=pk,
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
serializer = EstimatePointSerializer(
|
||||
estimate_point, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save(estimate_id=estimate_id, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except EstimatePoint.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate Point does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The estimate point value is already taken"},
|
||||
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,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
@@ -141,17 +42,35 @@ class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def post(self, request, slug, project_id, estimate_id):
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project=project_id
|
||||
estimates = Estimate.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).prefetch_related("points").select_related("workspace", "project")
|
||||
serializer = EstimateReadSerializer(estimates, 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 create(self, request, slug, project_id):
|
||||
try:
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
|
||||
if not len(estimate_points) or len(estimate_points) > 8:
|
||||
@@ -160,6 +79,18 @@ class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
try:
|
||||
estimate = estimate_serializer.save(project_id=project_id)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"errror": "Estimate with the name already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
estimate_points = EstimatePoint.objects.bulk_create(
|
||||
[
|
||||
EstimatePoint(
|
||||
@@ -178,9 +109,17 @@ class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
estimate_point_serializer = EstimatePointSerializer(
|
||||
estimate_points, many=True
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate does not exist"},
|
||||
@@ -193,14 +132,58 @@ class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, project_id, estimate_id):
|
||||
def retrieve(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = EstimateReadSerializer(estimate)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not len(request.data.get("estimate_points", [])):
|
||||
return Response(
|
||||
{"error": "Estimate points are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
estimate_serializer = EstimateSerializer(
|
||||
estimate, data=request.data.get("estimate"), partial=True
|
||||
)
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
try:
|
||||
estimate = estimate_serializer.save()
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"errror": "Estimate with the name already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
@@ -212,7 +195,6 @@ class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
estimate_id=estimate_id,
|
||||
)
|
||||
|
||||
print(estimate_points)
|
||||
updated_estimate_points = []
|
||||
for estimate_point in estimate_points:
|
||||
# Find the data for that estimate point
|
||||
@@ -221,24 +203,50 @@ class BulkEstimatePointEndpoint(BaseAPIView):
|
||||
for point in estimate_points_data
|
||||
if point.get("id") == str(estimate_point.id)
|
||||
]
|
||||
print(estimate_point_data)
|
||||
if len(estimate_point_data):
|
||||
estimate_point.value = estimate_point_data[0].get(
|
||||
"value", estimate_point.value
|
||||
)
|
||||
updated_estimate_points.append(estimate_point)
|
||||
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points, ["value"], batch_size=10
|
||||
try:
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points, ["value"], batch_size=10,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
return Response(
|
||||
{"error": "Values need to be unique for each key"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
print(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, estimate_id):
|
||||
try:
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -28,6 +28,7 @@ from plane.db.models import (
|
||||
Module,
|
||||
ModuleLink,
|
||||
ModuleIssue,
|
||||
Label,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ImporterSerializer,
|
||||
@@ -104,7 +105,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -235,9 +236,20 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
|
||||
def delete(self, request, slug, service, pk):
|
||||
try:
|
||||
importer = Importer.objects.filter(
|
||||
importer = Importer.objects.get(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
# Delete all imported Issues
|
||||
imported_issues = importer.imported_data.get("issues", [])
|
||||
Issue.objects.filter(id__in=imported_issues).delete()
|
||||
|
||||
# Delete all imported Labels
|
||||
imported_labels = importer.imported_data.get("labels", [])
|
||||
Label.objects.filter(id__in=imported_labels).delete()
|
||||
|
||||
if importer.service == "jira":
|
||||
imported_modules = importer.imported_data.get("modules", [])
|
||||
Module.objects.filter(id__in=imported_modules).delete()
|
||||
importer.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
@@ -247,6 +259,27 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, service, pk):
|
||||
try:
|
||||
importer = Importer.objects.get(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
serializer = ImporterSerializer(importer, 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 Importer.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Importer Does not exists"}, 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 UpdateServiceImportStatusEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service, importer_id):
|
||||
@@ -330,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", None),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -367,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for label_id in labels_list
|
||||
]
|
||||
@@ -387,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for assignee_id in assignees_list
|
||||
]
|
||||
@@ -406,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
workspace_id=project.workspace_id,
|
||||
comment=f"{request.user.email} importer the issue from {service}",
|
||||
verb="created",
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
@@ -424,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for comment in comments_list
|
||||
]
|
||||
@@ -441,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue, issue_data in zip(issues, issues_data)
|
||||
]
|
||||
@@ -479,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module in modules_data
|
||||
],
|
||||
@@ -487,48 +517,57 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
_ = ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=module,
|
||||
url=module_data.get("link", {}).get("url", "https://plane.so"),
|
||||
title=module_data.get("link", {}).get(
|
||||
"title", "Original Issue"
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module, module_data in zip(modules, modules_data)
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
modules = Module.objects.filter(id__in=[module.id for module in modules])
|
||||
|
||||
bulk_module_issues = []
|
||||
for module, module_data in zip(modules, modules_data):
|
||||
module_issues_list = module_data.get("module_issues_list", [])
|
||||
bulk_module_issues = bulk_module_issues + [
|
||||
ModuleIssue(
|
||||
issue_id=issue,
|
||||
module=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in module_issues_list
|
||||
]
|
||||
if len(modules) == len(modules_data):
|
||||
_ = ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=module,
|
||||
url=module_data.get("link", {}).get(
|
||||
"url", "https://plane.so"
|
||||
),
|
||||
title=module_data.get("link", {}).get(
|
||||
"title", "Original Issue"
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for module, module_data in zip(modules, modules_data)
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
bulk_module_issues, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
bulk_module_issues = []
|
||||
for module, module_data in zip(modules, modules_data):
|
||||
module_issues_list = module_data.get("module_issues_list", [])
|
||||
bulk_module_issues = bulk_module_issues + [
|
||||
ModuleIssue(
|
||||
issue_id=issue,
|
||||
module=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue in module_issues_list
|
||||
]
|
||||
|
||||
serializer = ModuleSerializer(modules, many=True)
|
||||
return Response(
|
||||
{"modules": serializer.data}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
bulk_module_issues, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ModuleSerializer(modules, many=True)
|
||||
return Response(
|
||||
{"modules": serializer.data}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{"message": "Modules created but issues could not be imported"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -6,3 +6,4 @@ from .github import (
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
from .slack import SlackProjectSyncViewSet
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.utils.integrations.github import (
|
||||
)
|
||||
from plane.api.permissions import WorkSpaceAdminPermission
|
||||
|
||||
|
||||
class IntegrationViewSet(BaseViewSet):
|
||||
serializer_class = IntegrationSerializer
|
||||
model = Integration
|
||||
@@ -101,7 +102,6 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
@@ -112,21 +112,30 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
|
||||
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":
|
||||
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,
|
||||
)
|
||||
metadata = get_github_metadata(installation_id)
|
||||
config = {"installation_id": installation_id}
|
||||
|
||||
if provider == "slack":
|
||||
metadata = request.data.get("metadata", {})
|
||||
access_token = metadata.get("access_token", False)
|
||||
team_id = metadata.get("team", {}).get("id", False)
|
||||
if not metadata or not access_token or not team_id:
|
||||
return Response(
|
||||
{"error": "Access token and team id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
config = {"team_id": team_id, "access_token": access_token}
|
||||
|
||||
# Create a bot user
|
||||
bot_user = User.objects.create(
|
||||
email=f"{uuid.uuid4().hex}@plane.so",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Django import
|
||||
from django.db import IntegrityError
|
||||
|
||||
# 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 SlackProjectSync, WorkspaceIntegration, ProjectMember
|
||||
from plane.api.serializers import SlackProjectSyncSerializer
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
|
||||
|
||||
class SlackProjectSyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
serializer_class = SlackProjectSyncSerializer
|
||||
model = SlackProjectSync
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
try:
|
||||
serializer = SlackProjectSyncSerializer(data=request.data)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
workspace_integration_id=workspace_integration_id,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=workspace_integration_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,13 +1,14 @@
|
||||
# Python imports
|
||||
import json
|
||||
import random
|
||||
from itertools import groupby, chain
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -46,6 +47,7 @@ from plane.db.models import (
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@@ -590,8 +592,31 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
serializer = IssueLiteSerializer(sub_issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
state_distribution = (
|
||||
State.objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
filter=Q(state_issue__parent_id=issue_id),
|
||||
)
|
||||
)
|
||||
.order_by("group")
|
||||
.values("group", "state_count")
|
||||
)
|
||||
|
||||
result = {item["group"]: item["state_count"] for item in state_distribution}
|
||||
|
||||
serializer = IssueLiteSerializer(
|
||||
sub_issues,
|
||||
many=True,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sub_issues": serializer.data,
|
||||
"state_distribution": result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
|
||||
@@ -109,6 +109,28 @@ class ModuleViewSet(BaseViewSet):
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(self.kwargs.get("pk")),
|
||||
"issues": [str(issue_id) for issue_id in module_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=None,
|
||||
)
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
@@ -158,6 +180,22 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
module_id=self.kwargs.get("module_id"),
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(self.kwargs.get("module_id")),
|
||||
"issues": [str(instance.issue_id)],
|
||||
}
|
||||
),
|
||||
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=None,
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -302,7 +340,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"modules_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
|
||||
@@ -14,7 +14,7 @@ from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework import status
|
||||
|
||||
from sentry_sdk import capture_exception
|
||||
# sso authentication
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests as google_auth_request
|
||||
@@ -48,7 +48,7 @@ def validate_google_token(token, client_id):
|
||||
}
|
||||
return data
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
raise exceptions.AuthenticationFailed("Error with Google connection.")
|
||||
|
||||
|
||||
@@ -305,8 +305,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
)
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{
|
||||
"error": "Something went wrong. Please try again later or contact the support team."
|
||||
|
||||
@@ -96,6 +96,36 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
# Only update access if the page owner is the requesting user
|
||||
if (
|
||||
page.access != request.data.get("access", page.access)
|
||||
and page.owned_by_id != request.user.id
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Access cannot be updated since this page is owned by someone else"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = PageSerializer(page, 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 Page.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Page Does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class PageBlockViewSet(BaseViewSet):
|
||||
serializer_class = PageBlockSerializer
|
||||
@@ -344,7 +374,7 @@ class RecentPagesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q, Exists, OuterRef
|
||||
from django.db.models import Q, Exists, OuterRef, Func, F
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@@ -46,6 +46,8 @@ from plane.db.models import (
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
ProjectIdentifier,
|
||||
Cycle,
|
||||
Module,
|
||||
)
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
@@ -92,6 +94,26 @@ class ProjectViewSet(BaseViewSet):
|
||||
self.get_queryset()
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by("-is_favorite", "name")
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_modules=Module.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
@@ -161,6 +183,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
@@ -344,6 +367,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
workspace=invitation.project.workspace,
|
||||
member=request.user,
|
||||
role=invitation.role,
|
||||
created_by=request.user,
|
||||
)
|
||||
for invitation in project_invitations
|
||||
]
|
||||
@@ -385,6 +409,41 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.data.get("role", 10) > project_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(
|
||||
project_member, 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 ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
||||
class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
@@ -465,6 +524,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
member_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -612,6 +672,7 @@ class ProjectJoinEndpoint(BaseAPIView):
|
||||
if workspace_role >= 15
|
||||
else (15 if workspace_role == 10 else workspace_role),
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
for project_id in project_ids
|
||||
],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, 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,
|
||||
)
|
||||
@@ -195,7 +195,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -8,10 +11,10 @@ from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import StateSerializer
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import State
|
||||
from plane.db.models import State, Issue
|
||||
|
||||
|
||||
class StateViewSet(BaseViewSet):
|
||||
@@ -36,6 +39,25 @@ class StateViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "State with the name already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
state_dict = dict()
|
||||
@@ -66,6 +88,17 @@ class StateViewSet(BaseViewSet):
|
||||
{"error": "Default state cannot be deleted"}, status=False
|
||||
)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.objects.filter(state=pk).exists()
|
||||
|
||||
if issue_exist:
|
||||
return Response(
|
||||
{
|
||||
"error": "The state is not empty, only empty states can be deleted"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
state.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except State.DoesNotExist:
|
||||
|
||||
@@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueViewFavorite,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
@@ -145,7 +145,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -224,6 +223,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
algorithm="HS256",
|
||||
),
|
||||
role=email.get("role", 10),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
except ValidationError:
|
||||
@@ -333,7 +333,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -383,6 +382,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
workspace=invitation.workspace,
|
||||
member=request.user,
|
||||
role=invitation.role,
|
||||
created_by=request.user,
|
||||
)
|
||||
for invitation in workspace_invitations
|
||||
],
|
||||
@@ -423,6 +423,43 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
.select_related("member")
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
try:
|
||||
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
|
||||
if request.user.id == workspace_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.data.get("role", 10) > workspace_member.role:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_member, 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 WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Member does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
@@ -780,9 +817,8 @@ class WorkspaceThemeViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
"state__group": "State Group",
|
||||
"labels__name": "Label",
|
||||
"assignees__email": "Assignee Name",
|
||||
"start_date": "Start Date",
|
||||
"target_date": "Due Date",
|
||||
"completed_at": "Completed At",
|
||||
"created_at": "Created At",
|
||||
"issue_count": "Issue Count",
|
||||
"priority": "Priority",
|
||||
"estimate": "Estimate",
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def analytic_export_task(email, data, slug):
|
||||
try:
|
||||
filters = issue_filters(data, "POST")
|
||||
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
|
||||
|
||||
x_axis = data.get("x_axis", False)
|
||||
y_axis = data.get("y_axis", False)
|
||||
segment = data.get("segment", False)
|
||||
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
|
||||
key = "count" if y_axis == "issue_count" else "estimate"
|
||||
|
||||
segmented = segment
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
if segment:
|
||||
segment_zero = []
|
||||
for item in distribution:
|
||||
current_dict = distribution.get(item)
|
||||
for current in current_dict:
|
||||
segment_zero.append(current.get("segment"))
|
||||
|
||||
segment_zero = list(set(segment_zero))
|
||||
row_zero = (
|
||||
[
|
||||
row_mapping.get(x_axis, "X-Axis"),
|
||||
]
|
||||
+ [
|
||||
row_mapping.get(y_axis, "Y-Axis"),
|
||||
]
|
||||
+ segment_zero
|
||||
)
|
||||
rows = []
|
||||
for item in distribution:
|
||||
generated_row = [
|
||||
item,
|
||||
]
|
||||
|
||||
data = distribution.get(item)
|
||||
# Add y axis values
|
||||
generated_row.append(sum(obj.get(key) for obj in data if obj.get(key, None) is not None))
|
||||
|
||||
for segment in segment_zero:
|
||||
value = [x for x in data if x.get("segment") == segment]
|
||||
if len(value):
|
||||
generated_row.append(value[0].get(key))
|
||||
else:
|
||||
generated_row.append("0")
|
||||
# x-axis replacement for names
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
|
||||
if segmented in ["assignees__email"]:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
# find the name of the user
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
|
||||
if len(assignee):
|
||||
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
rows = [tuple(row_zero)] + rows
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Export is ready"
|
||||
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
else:
|
||||
row_zero = [
|
||||
row_mapping.get(x_axis, "X-Axis"),
|
||||
row_mapping.get(y_axis, "Y-Axis"),
|
||||
]
|
||||
rows = []
|
||||
for item in distribution:
|
||||
row = [
|
||||
item,
|
||||
distribution.get(item)[0].get("count")
|
||||
if y_axis == "issue_count"
|
||||
else distribution.get(item)[0].get("estimate "),
|
||||
]
|
||||
# x-axis replacement to names
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
rows.append(tuple(row))
|
||||
rows = [tuple(row_zero)] + rows
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Export is ready"
|
||||
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -20,7 +21,7 @@ def email_verification(first_name, email, token, current_site):
|
||||
realtivelink = "/request-email-verification/" + "?token=" + str(token)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -18,7 +19,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.db.models import (
|
||||
User,
|
||||
)
|
||||
from .workspace_invitation_task import workspace_invitation
|
||||
from plane.bgtasks.user_welcome_task import send_welcome_slack
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -40,7 +41,7 @@ def service_importer(service, importer_id):
|
||||
|
||||
# Check if we need to import users as well
|
||||
if len(users):
|
||||
# For all invited users create the uers
|
||||
# For all invited users create the users
|
||||
new_users = User.objects.bulk_create(
|
||||
[
|
||||
User(
|
||||
@@ -56,6 +57,15 @@ def service_importer(service, importer_id):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
[
|
||||
send_welcome_slack.delay(
|
||||
str(user.id),
|
||||
True,
|
||||
f"{user.email} was imported to Plane from {service}",
|
||||
)
|
||||
for user in new_users
|
||||
]
|
||||
|
||||
workspace_users = User.objects.filter(
|
||||
email__in=[
|
||||
user.get("email").strip().lower()
|
||||
@@ -68,7 +78,11 @@ def service_importer(service, importer_id):
|
||||
# Add new users to Workspace and project automatically
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
|
||||
WorkspaceMember(
|
||||
member=user,
|
||||
workspace_id=importer.workspace_id,
|
||||
created_by=importer.created_by,
|
||||
)
|
||||
for user in workspace_users
|
||||
],
|
||||
batch_size=100,
|
||||
@@ -81,6 +95,7 @@ def service_importer(service, importer_id):
|
||||
project_id=importer.project_id,
|
||||
workspace_id=importer.workspace_id,
|
||||
member=user,
|
||||
created_by=importer.created_by,
|
||||
)
|
||||
for user in workspace_users
|
||||
],
|
||||
|
||||
@@ -136,7 +136,6 @@ def track_priority(
|
||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
)
|
||||
print(issue_activities)
|
||||
|
||||
|
||||
# Track chnages in state of the issue
|
||||
@@ -506,119 +505,6 @@ def track_blockings(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
@@ -683,8 +569,6 @@ def update_issue_activity(
|
||||
"assignees_list": track_assignees,
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"cycles_list": track_cycles,
|
||||
"modules_list": track_modules,
|
||||
"estimate_point": track_estimate_points,
|
||||
}
|
||||
|
||||
@@ -788,6 +672,177 @@ def delete_comment_activity(
|
||||
)
|
||||
|
||||
|
||||
def create_cycle_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
# 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 delete_cycle_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
cycle_id = requested_data.get("cycle_id", "")
|
||||
cycle = Cycle.objects.filter(pk=cycle_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=cycle.name if cycle is not None else "",
|
||||
new_value="",
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
|
||||
old_identifier=cycle.id if cycle is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_module_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
# 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 delete_module_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
module_id = requested_data.get("module_id", "")
|
||||
module = Module.objects.filter(pk=module_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=module.name if module is not None else "",
|
||||
new_value="",
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
|
||||
old_identifier=module.id if module is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
@@ -910,6 +965,10 @@ def issue_activity(
|
||||
"comment.activity.created": create_comment_activity,
|
||||
"comment.activity.updated": update_comment_activity,
|
||||
"comment.activity.deleted": delete_comment_activity,
|
||||
"cycle.activity.created": create_cycle_issue_activity,
|
||||
"cycle.activity.deleted": delete_cycle_issue_activity,
|
||||
"module.activity.created": create_module_issue_activity,
|
||||
"module.activity.deleted": delete_module_issue_activity,
|
||||
"link.activity.created": create_link_activity,
|
||||
"link.activity.updated": update_link_activity,
|
||||
"link.activity.deleted": delete_link_activity,
|
||||
@@ -947,6 +1006,5 @@ def issue_activity(
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -14,7 +15,7 @@ def magic_link(email, key, token, current_site):
|
||||
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Login for Plane"
|
||||
|
||||
@@ -29,6 +30,5 @@ def magic_link(email, key, token, current_site):
|
||||
msg.send()
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,7 +23,7 @@ def project_invitation(email, project_id, token, current_site):
|
||||
relativelink = f"/project-member-invitation/{project_member_invite.id}"
|
||||
abs_url = "http://" + current_site + relativelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane"
|
||||
|
||||
@@ -49,6 +50,5 @@ def project_invitation(email, project_id, token, current_site):
|
||||
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e:
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_welcome_slack(user_id, created, message):
|
||||
try:
|
||||
instance = User.objects.get(pk=user_id)
|
||||
|
||||
if created and not instance.is_bot:
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
try:
|
||||
_ = client.chat_postMessage(
|
||||
channel="#trackers",
|
||||
text=message,
|
||||
)
|
||||
except SlackApiError as e:
|
||||
print(f"Got an error: {e.response['error']}")
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -27,7 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"{invitor or email} invited you to join {workspace.name} on Plane"
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-01 19:56
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0028_auto_20230414_1703'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cycle',
|
||||
name='view_props',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importer',
|
||||
name='imported_data',
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='view_props',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SlackProjectSync',
|
||||
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)),
|
||||
('access_token', models.CharField(max_length=300)),
|
||||
('scopes', models.TextField()),
|
||||
('bot_user_id', models.CharField(max_length=50)),
|
||||
('webhook_url', models.URLField(max_length=1000)),
|
||||
('data', models.JSONField(default=dict)),
|
||||
('team_id', models.CharField(max_length=30)),
|
||||
('team_name', models.CharField(max_length=300)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_slackprojectsync', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_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_slackprojectsync', to='db.workspace')),
|
||||
('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slack_syncs', to='db.workspaceintegration')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Slack Project Sync',
|
||||
'verbose_name_plural': 'Slack Project Syncs',
|
||||
'db_table': 'slack_project_syncs',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('team_id', 'project')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-05 14:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0029_auto_20230502_0126'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='estimatepoint',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-12 11:31
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0030_alter_estimatepoint_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnalyticView',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('query', models.JSONField()),
|
||||
('query_dict', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_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='analyticview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Analytic',
|
||||
'verbose_name_plural': 'Analytics',
|
||||
'db_table': 'analytic_views',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-20 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0031_analyticview'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='project',
|
||||
old_name='icon',
|
||||
new_name='emoji',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='icon_prop',
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -59,6 +59,7 @@ from .integration import (
|
||||
GithubRepositorySync,
|
||||
GithubIssueSync,
|
||||
GithubCommentSync,
|
||||
SlackProjectSync,
|
||||
)
|
||||
|
||||
from .importer import Importer
|
||||
@@ -66,3 +67,5 @@ from .importer import Importer
|
||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .analytic import AnalyticView
|
||||
@@ -0,0 +1,25 @@
|
||||
# Django models
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class AnalyticView(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="analytics", on_delete=models.CASCADE
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
query = models.JSONField()
|
||||
query_dict = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Analytic"
|
||||
verbose_name_plural = "Analytics"
|
||||
db_table = "analytic_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the analytic view"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
@@ -16,6 +16,7 @@ class Cycle(ProjectBaseModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owned_by_cycle",
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
|
||||
@@ -39,7 +39,6 @@ class EstimatePoint(ProjectBaseModel):
|
||||
return f"{self.estimate.name} <{self.key}> <{self.value}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["value", "estimate"]
|
||||
verbose_name = "Estimate Point"
|
||||
verbose_name_plural = "Estimate Points"
|
||||
db_table = "estimate_points"
|
||||
|
||||
@@ -33,6 +33,7 @@ class Importer(ProjectBaseModel):
|
||||
token = models.ForeignKey(
|
||||
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
|
||||
)
|
||||
imported_data = models.JSONField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Importer"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
|
||||
from .slack import SlackProjectSync
|
||||
@@ -0,0 +1,32 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
|
||||
|
||||
class SlackProjectSync(ProjectBaseModel):
|
||||
access_token = models.CharField(max_length=300)
|
||||
scopes = models.TextField()
|
||||
bot_user_id = models.CharField(max_length=50)
|
||||
webhook_url = models.URLField(max_length=1000)
|
||||
data = models.JSONField(default=dict)
|
||||
team_id = models.CharField(max_length=30)
|
||||
team_name = models.CharField(max_length=300)
|
||||
workspace_integration = models.ForeignKey(
|
||||
"db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo name"""
|
||||
return f"{self.project.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team_id", "project"]
|
||||
verbose_name = "Slack Project Sync"
|
||||
verbose_name_plural = "Slack Project Syncs"
|
||||
db_table = "slack_project_syncs"
|
||||
ordering = ("-created_at",)
|
||||
@@ -85,8 +85,13 @@ class Issue(ProjectBaseModel):
|
||||
).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()
|
||||
random_state = State.objects.filter(project=self.project).first()
|
||||
self.state = random_state
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
if default_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -94,18 +99,15 @@ class Issue(ProjectBaseModel):
|
||||
try:
|
||||
from plane.db.models import State, PageBlock
|
||||
|
||||
# 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:
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
# check if there are any page blocks
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=timezone.now()
|
||||
)
|
||||
|
||||
elif self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=None
|
||||
@@ -116,7 +118,6 @@ class Issue(ProjectBaseModel):
|
||||
pass
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
@@ -131,6 +132,9 @@ class Issue(ProjectBaseModel):
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# If adding it to started state
|
||||
if self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
|
||||
@@ -39,6 +39,7 @@ class Module(ProjectBaseModel):
|
||||
through="ModuleMember",
|
||||
through_fields=("module", "member"),
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
|
||||
@@ -63,7 +63,8 @@ class Project(BaseModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
|
||||
@@ -104,29 +104,9 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_email(sender, instance, created, **kwargs):
|
||||
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||
try:
|
||||
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>"
|
||||
|
||||
subject = f"Welcome to Plane ✈️!"
|
||||
|
||||
context = {"first_name": first_name, "email": instance.email}
|
||||
|
||||
html_content = render_to_string(
|
||||
"emails/auth/user_welcome_email.html", context
|
||||
)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, from_email_string, [to_email]
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
|
||||
@@ -174,11 +174,12 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
# Host for sending e-mail.
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST")
|
||||
# Port for sending e-mail.
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
||||
# Optional SMTP authentication information for EMAIL_HOST.
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1"
|
||||
EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>")
|
||||
|
||||
|
||||
SIMPLE_JWT = {
|
||||
@@ -210,4 +211,4 @@ SIMPLE_JWT = {
|
||||
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
|
||||
@@ -83,3 +83,6 @@ LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
|
||||
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
@@ -105,7 +105,7 @@ if (
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
@@ -240,7 +240,15 @@ SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
broker_url = (
|
||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
)
|
||||
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
if DOCKERIZED:
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
else:
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -80,7 +80,7 @@ AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
@@ -203,4 +203,6 @@ redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
||||
|
||||
|
||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
|
||||
temp_axis = x_axis
|
||||
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
year = ExtractYear(x_axis)
|
||||
month = ExtractMonth(x_axis)
|
||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||
queryset = queryset.annotate(dimension=dimension)
|
||||
x_axis = "dimension"
|
||||
else:
|
||||
queryset = queryset.annotate(dimension=F(x_axis))
|
||||
x_axis = "dimension"
|
||||
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
queryset = queryset.exclude(x_axis__is_null=True)
|
||||
|
||||
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
year = ExtractYear(segment)
|
||||
month = ExtractMonth(segment)
|
||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||
queryset = queryset.annotate(segmented=dimension)
|
||||
segment = "segmented"
|
||||
|
||||
queryset = queryset.values(x_axis)
|
||||
|
||||
# Group queryset by x_axis field
|
||||
|
||||
if y_axis == "issue_count":
|
||||
queryset = queryset.annotate(
|
||||
is_null=Case(
|
||||
When(dimension__isnull=True, then=Value("None")),
|
||||
default=Value("not_null"),
|
||||
output_field=models.CharField(max_length=8),
|
||||
),
|
||||
dimension_ex=Coalesce("dimension", Value("null")),
|
||||
).values("dimension")
|
||||
if segment:
|
||||
queryset = queryset.annotate(segment=F(segment)).values(
|
||||
"dimension", "segment"
|
||||
)
|
||||
else:
|
||||
queryset = queryset.values("dimension")
|
||||
|
||||
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
|
||||
|
||||
if y_axis == "estimate":
|
||||
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(x_axis)
|
||||
if segment:
|
||||
queryset = queryset.annotate(segment=F(segment)).values(
|
||||
"dimension", "segment", "estimate"
|
||||
)
|
||||
else:
|
||||
queryset = queryset.values("dimension", "estimate")
|
||||
|
||||
result_values = list(queryset)
|
||||
grouped_data = {}
|
||||
for key, items in groupby(result_values, key=lambda x: x[str("dimension")]):
|
||||
grouped_data[str(key)] = list(items)
|
||||
|
||||
sorted_data = grouped_data
|
||||
if temp_axis == "priority":
|
||||
order = ["low", "medium", "high", "urgent", "None"]
|
||||
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||
else:
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||
return sorted_data
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import urlparse, parse_qs
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_jwt_token():
|
||||
@@ -128,3 +129,24 @@ def get_github_repo_details(access_tokens_url, owner, repo):
|
||||
).json()
|
||||
|
||||
return open_issues, total_labels, collaborators
|
||||
|
||||
|
||||
def get_release_notes():
|
||||
token = settings.GITHUB_ACCESS_TOKEN
|
||||
|
||||
if token:
|
||||
headers = {
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
else:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1"
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {"error": "Unable to render information from Github Repository"}
|
||||
|
||||
return response.json()
|
||||
|
||||
@@ -13,6 +13,17 @@ def filter_state(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_estimate_point(params, filter, method):
|
||||
if method == "GET":
|
||||
estimate_points = params.get("estimate_point").split(",")
|
||||
if len(estimate_points) and "" not in estimate_points:
|
||||
filter["estimate_point__in"] = estimate_points
|
||||
else:
|
||||
if params.get("estimate_point", None) and len(params.get("estimate_point")):
|
||||
filter["estimate_point__in"] = params.get("estimate_point")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_priority(params, filter, method):
|
||||
if method == "GET":
|
||||
priorties = params.get("priority").split(",")
|
||||
@@ -187,11 +198,45 @@ def filter_issue_state_type(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_project(params, filter, method):
|
||||
if method == "GET":
|
||||
projects = params.get("project").split(",")
|
||||
if len(projects) and "" not in projects:
|
||||
filter["project__in"] = projects
|
||||
else:
|
||||
if params.get("project", None) and len(params.get("project")):
|
||||
filter["project__in"] = params.get("project")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_cycle(params, filter, method):
|
||||
if method == "GET":
|
||||
cycles = params.get("cycle").split(",")
|
||||
if len(cycles) and "" not in cycles:
|
||||
filter["issue_cycle__cycle_id__in"] = cycles
|
||||
else:
|
||||
if params.get("cycle", None) and len(params.get("cycle")):
|
||||
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_module(params, filter, method):
|
||||
if method == "GET":
|
||||
modules = params.get("module").split(",")
|
||||
if len(modules) and "" not in modules:
|
||||
filter["issue_module__module_id__in"] = modules
|
||||
else:
|
||||
if params.get("module", None) and len(params.get("module")):
|
||||
filter["issue_module__module_id__in"] = params.get("module")
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
"estimate_point": filter_estimate_point,
|
||||
"priority": filter_priority,
|
||||
"parent": filter_parent,
|
||||
"labels": filter_labels,
|
||||
@@ -204,6 +249,9 @@ def issue_filters(query_params, method):
|
||||
"target_date": filter_target_date,
|
||||
"completed_at": filter_completed_at,
|
||||
"type": filter_issue_state_type,
|
||||
"project": filter_project,
|
||||
"cycle": filter_cycle,
|
||||
"module": filter_module,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# base requirements
|
||||
|
||||
Django==3.2.18
|
||||
Django==3.2.19
|
||||
django-braces==1.15.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Plane ✈️!</title>
|
||||
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
|
||||
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
|
||||
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-i { padding-bottom: 15px !important; padding-top: 15px !important } .r10-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r11-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r12-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: left !important } .r13-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-r { background-color: #ffffff !important; border-color: #000000 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r20-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r21-c { box-sizing: border-box !important; width: 100% !important } .r22-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r23-c { box-sizing: border-box !important; width: 32px !important } .r24-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r25-i { padding-bottom: 5px !important; padding-top: 5px !important } .r26-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r27-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r28-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r29-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r30-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
|
||||
<!--[if !mso]><!-->
|
||||
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
|
||||
<tr>
|
||||
<td align="center" class="r0-c">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
|
||||
<tr>
|
||||
<td valign="top" class="r2-i" style="background-color: #ffffff;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r5-i" style="background-color: #f8f9fa;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td valign="top" class="r8-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r4-o" style="table-layout: fixed; width: 120px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r12-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Welcome to Plane!</h3>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;">We're thrilled you're here. We know this is the beginning of a long and exciting<br>journey, and we want to be there every step of the way.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;"><strong>Plane is an open-source issue planning and tracking tool</strong> that allows teams to collaborate on projects and prioritize tasks. With Plane, you can easily create and assign issues, set deadlines, and track progress.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;">We have put together some resources to help you get started. Please find them below:</p>
|
||||
<p style="margin: 0;"> </p>
|
||||
<ul style="margin: 0; margin-top:20px;">
|
||||
<li><a href="https://docs.plane.so/quick-start" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
|
||||
<li><a href="https://plane.so/changelog" target="_blank" style="color: #0092ff; text-decoration: underline;">Plane Changelog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href="https://app.plane.so/" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
||||
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;">Also, if you like Plane, please consider starring us on GitHub. This helps us to grow our community and make Plane even better.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane/plane" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#000000" strokeweight="1px" data-btn="2">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
<p><span style="color:#000000;font-size:14px;">⭐ Star us on GitHub</span></p>
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href="https://github.com/makeplane/plane" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #000000; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
||||
<p style="margin: 0;"><span style="color: #000000; font-size: 14px;">⭐ Star us on GitHub</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;"><span style="font-size: 12px;">Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r18-i" style="background-color: #eff2f7;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td valign="top" class="r8-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r4-o" style="table-layout: fixed; width: 57px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr>
|
||||
<td align="center" valign="top" class="r19-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
||||
<div>
|
||||
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r20-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r4-o" style="table-layout: fixed; width: 570px;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td valign="top" class="">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r21-c" style="display: inline-block;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td class="r22-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="32" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" class="r27-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
||||
<div>
|
||||
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html>
|
||||
Hey there,<br/>
|
||||
Your requested data export from Plane Analytics is now ready. The information has been compiled into a CSV format for your convenience.<br/>
|
||||
Please find the attachment and download the CSV file. This file can easily be imported into any spreadsheet program for further analysis.<br/>
|
||||
If you require any assistance or have any questions, please do not hesitate to contact us.<br/>
|
||||
Thank you
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
# Replace with your instance Public IP
|
||||
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
+24
-3
@@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -12,10 +13,10 @@ RUN turbo prune --scope=app --docker
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
@@ -26,9 +27,17 @@ RUN yarn install
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -43,8 +52,20 @@ 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
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next ./apps/app/.next
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
|
||||
@@ -92,13 +92,13 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
<>
|
||||
<form className="space-y-5 py-5 px-5">
|
||||
{(codeSent || codeResent) && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<div className="rounded-md bg-green-500/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
<p className="text-sm font-medium text-green-500">
|
||||
{codeResent
|
||||
? "Please check your mail for new code."
|
||||
: "Please check your mail for code."}
|
||||
@@ -141,7 +141,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
<button
|
||||
type="button"
|
||||
className={`mt-5 flex w-full justify-end text-xs outline-none ${
|
||||
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
|
||||
isResendDisabled
|
||||
? "cursor-default text-brand-secondary"
|
||||
: "cursor-pointer text-brand-accent"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
@@ -174,7 +176,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
|
||||
@@ -50,7 +50,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
if (!error?.response?.data) return;
|
||||
Object.keys(error.response.data).forEach((key) => {
|
||||
const err = error.response.data[key];
|
||||
console.log("err", err);
|
||||
console.log(err);
|
||||
setError(key as keyof EmailPasswordFormValues, {
|
||||
type: "manual",
|
||||
message: Array.isArray(err) ? err.join(", ") : err,
|
||||
@@ -94,7 +94,9 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
<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>
|
||||
<a className="font-medium text-brand-accent hover:text-brand-accent">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +104,8 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
<SecondaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||
</SecondaryButton>
|
||||
|
||||
@@ -33,11 +33,11 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-1 w-full">
|
||||
<div className="w-full px-1">
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-gray-200 p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
|
||||
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base p-2 text-sm font-medium text-brand-secondary duration-300 hover:bg-brand-surface-2">
|
||||
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
|
||||
<span>Sign In with Github</span>
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
|
||||
<div className="overflow-hidden rounded" id="googleSignInButton" ref={googleSignInButton} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
// types
|
||||
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
params?: IAnalyticsParams;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const payload: ISaveAnalyticsFormData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
query_dict: {
|
||||
x_axis: "priority",
|
||||
y_axis: "issue_count",
|
||||
...params,
|
||||
project: params?.project ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
await analyticsService
|
||||
.saveAnalytics(workspaceSlug.toString(), payload)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Analytics saved successfully.",
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Analytics could not be saved. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<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-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 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}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
||||
Save Analytics
|
||||
</Dialog.Title>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
width="full"
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
}}
|
||||
/>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
className="mt-3 h-32 resize-none text-sm"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Analytics"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, UseFormSetValue } from "react-hook-form";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
// components
|
||||
import {
|
||||
AnalyticsGraph,
|
||||
AnalyticsSelectBar,
|
||||
AnalyticsSidebar,
|
||||
AnalyticsTable,
|
||||
} from "components/analytics";
|
||||
// ui
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// helpers
|
||||
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
analyticsError: any;
|
||||
params: IAnalyticsParams;
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
fullScreen: boolean;
|
||||
};
|
||||
|
||||
export const CustomAnalytics: React.FC<Props> = ({
|
||||
analytics,
|
||||
analyticsError,
|
||||
params,
|
||||
control,
|
||||
setValue,
|
||||
fullScreen,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
|
||||
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden flex flex-col-reverse ${
|
||||
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-3 flex flex-col h-full overflow-hidden">
|
||||
<AnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
projects={projects}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
{!analyticsError ? (
|
||||
analytics ? (
|
||||
analytics.total > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<AnalyticsGraph
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-6 p-5">
|
||||
<Loader.Item height="300px" />
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
</Loader>
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnalyticsSidebar
|
||||
analytics={analytics}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// nivo
|
||||
import { BarTooltipProps } from "@nivo/bar";
|
||||
import { DATE_KEYS } from "constants/analytics";
|
||||
import { renderMonthAndYear } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
datum: BarTooltipProps<any>;
|
||||
analytics: IAnalyticsResponse;
|
||||
params: IAnalyticsParams;
|
||||
};
|
||||
|
||||
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
|
||||
let tooltipValue: string | number = "";
|
||||
|
||||
if (params.segment) {
|
||||
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
|
||||
else if (params.segment === "assignees__email") {
|
||||
const assignee = analytics.extras.assignee_details.find(
|
||||
(a) => a.assignees__email === datum.id
|
||||
);
|
||||
|
||||
if (assignee)
|
||||
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
else tooltipValue = "No assignees";
|
||||
} else tooltipValue = datum.id;
|
||||
} else {
|
||||
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
|
||||
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: datum.color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium text-brand-secondary ${
|
||||
params.segment
|
||||
? params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
: params.x_axis === "priority" || params.x_axis === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{tooltipValue}:
|
||||
</span>
|
||||
<span>{datum.value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// components
|
||||
import { CustomTooltip } from "./custom-tooltip";
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
// helpers
|
||||
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
import { generateBarColor } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "count" | "estimate";
|
||||
fullScreen: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsGraph: React.FC<Props> = ({
|
||||
analytics,
|
||||
barGraphData,
|
||||
params,
|
||||
yAxisKey,
|
||||
fullScreen,
|
||||
}) => {
|
||||
const generateYAxisTickValues = () => {
|
||||
if (!analytics) return [];
|
||||
|
||||
let data: number[] = [];
|
||||
|
||||
if (params.segment)
|
||||
// find the total no of issues in each segment
|
||||
data = Object.keys(analytics.distribution).map((segment) => {
|
||||
let totalSegmentIssues = 0;
|
||||
|
||||
analytics.distribution[segment].map((s) => {
|
||||
totalSegmentIssues += s[yAxisKey] as number;
|
||||
});
|
||||
|
||||
return totalSegmentIssues;
|
||||
});
|
||||
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
|
||||
|
||||
return (
|
||||
<BarGraph
|
||||
data={barGraphData.data}
|
||||
indexBy="name"
|
||||
keys={barGraphData.xAxisKeys}
|
||||
colors={(datum) =>
|
||||
generateBarColor(
|
||||
params.segment ? `${datum.id}` : `${datum.indexValue}`,
|
||||
analytics,
|
||||
params,
|
||||
params.segment ? "segment" : "x_axis"
|
||||
)
|
||||
}
|
||||
customYAxisTickValues={generateYAxisTickValues()}
|
||||
tooltip={(datum) => <CustomTooltip datum={datum} analytics={analytics} params={params} />}
|
||||
height={fullScreen ? "400px" : "300px"}
|
||||
margin={{
|
||||
right: 20,
|
||||
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
|
||||
}}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 10,
|
||||
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
|
||||
renderTick:
|
||||
params.x_axis === "assignees__email"
|
||||
? (datum) => {
|
||||
const avatar = analytics.extras.assignee_details?.find(
|
||||
(a) => a?.assignees__email === datum?.value
|
||||
)?.assignees__avatar;
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<image
|
||||
x={-8}
|
||||
y={10}
|
||||
width={16}
|
||||
height={16}
|
||||
xlinkHref={avatar}
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{datum.value && datum.value !== "None"
|
||||
? `${datum.value}`.toUpperCase()[0]
|
||||
: "?"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./graph";
|
||||
export * from "./create-update-analytics-modal";
|
||||
export * from "./custom-analytics";
|
||||
export * from "./select-bar";
|
||||
export * from "./sidebar";
|
||||
export * from "./table";
|
||||
@@ -0,0 +1,80 @@
|
||||
// react-hook-form
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
// components
|
||||
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
|
||||
// types
|
||||
import { IAnalyticsParams, IProject } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
projects: IProject[];
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
control,
|
||||
setValue,
|
||||
projects,
|
||||
params,
|
||||
fullScreen,
|
||||
isProjectLevel,
|
||||
}) => (
|
||||
<div
|
||||
className={`grid items-center gap-4 px-5 py-2.5 ${
|
||||
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
|
||||
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
|
||||
>
|
||||
{!isProjectLevel && (
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Project</h6>
|
||||
<Controller
|
||||
name="project"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectProject value={value} onChange={onChange} projects={projects} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectYAxis value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val: string) => {
|
||||
if (params.segment === val) setValue("segment", null);
|
||||
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Group</h6>
|
||||
<Controller
|
||||
name="segment"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectSegment value={value} onChange={onChange} params={params} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
import projectService from "services/project.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CalendarDaysIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { NETWORK_CHOICES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
analytics,
|
||||
params,
|
||||
fullScreen,
|
||||
isProjectLevel = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? PROJECT_DETAILS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleDetails } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
cycleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleDetails } = useSWR(
|
||||
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
moduleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const exportAnalytics = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const data: IExportAnalyticsFormData = {
|
||||
x_axis: params.x_axis,
|
||||
y_axis: params.y_axis,
|
||||
};
|
||||
|
||||
if (params.segment) data.segment = params.segment;
|
||||
if (params.project) data.project = params.project;
|
||||
|
||||
analyticsService
|
||||
.exportAnalytics(workspaceSlug.toString(), data)
|
||||
.then((res) =>
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: res.message,
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "There was some error in exporting the analytics. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const selectedProjects =
|
||||
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
|
||||
fullScreen
|
||||
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<LayerDiagonalIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} Issues
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
{renderShortDate(
|
||||
(cycleId
|
||||
? cycleDetails?.created_at
|
||||
: moduleId
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
|
||||
<h4 className="font-medium">Selected Projects</h4>
|
||||
<div className="space-y-6 mt-4 h-full overflow-y-auto">
|
||||
{selectedProjects.map((projectId) => {
|
||||
const project: IProject = projects.find((p) => p.id === projectId);
|
||||
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(project.emoji))}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="break-all">
|
||||
{project.name}
|
||||
<span className="text-brand-secondary text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 pl-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_members}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total modules</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_modules}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{projectId ? (
|
||||
cycleId && cycleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<span>
|
||||
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<span>
|
||||
{cycleDetails.start_date && cycleDetails.start_date !== ""
|
||||
? renderShortDate(cycleDetails.start_date)
|
||||
: "No start date"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<span>
|
||||
{cycleDetails.end_date && cycleDetails.end_date !== ""
|
||||
? renderShortDate(cycleDetails.end_date)
|
||||
: "No end date"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : moduleId && moduleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<span>
|
||||
{moduleDetails.lead_detail?.first_name}{" "}
|
||||
{moduleDetails.lead_detail?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<span>
|
||||
{moduleDetails.start_date && moduleDetails.start_date !== ""
|
||||
? renderShortDate(moduleDetails.start_date)
|
||||
: "No start date"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<span>
|
||||
{moduleDetails.target_date && moduleDetails.target_date !== ""
|
||||
? renderShortDate(moduleDetails.target_date)
|
||||
: "No end date"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
|
||||
<div className="flex items-center gap-1">
|
||||
{projectDetails?.emoji ? (
|
||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(projectDetails.emoji))}
|
||||
</div>
|
||||
) : projectDetails?.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: projectDetails.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{projectDetails.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{projectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Network</h6>
|
||||
<span>
|
||||
{
|
||||
NETWORK_CHOICES[
|
||||
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-self-end">
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 -my-1">
|
||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</div>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={exportAnalytics}>
|
||||
<div className="flex items-center gap-2 -my-1">
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export as CSV
|
||||
</div>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// helpers
|
||||
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "count" | "estimate";
|
||||
};
|
||||
|
||||
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
|
||||
const renderAssigneeName = (email: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
|
||||
|
||||
if (!assignee) return "No assignee";
|
||||
|
||||
if (assignee.assignees__first_name !== "")
|
||||
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
|
||||
return email;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
|
||||
<thead className="bg-brand-surface-2">
|
||||
<tr className="divide-x divide-brand-base text-sm text-brand-base">
|
||||
<th scope="col" className="py-3 px-2.5 text-left font-medium">
|
||||
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
|
||||
</th>
|
||||
{params.segment ? (
|
||||
barGraphData.xAxisKeys.map((key) => (
|
||||
<th
|
||||
key={`segment-${key}`}
|
||||
scope="col"
|
||||
className={`px-2.5 py-3 text-left font-medium ${
|
||||
params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{params.segment === "priority" ? (
|
||||
getPriorityIcon(key)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(key, analytics, params, "segment"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{DATE_KEYS.includes(params.segment ?? "")
|
||||
? renderMonthAndYear(key)
|
||||
: params.segment === "assignees__email"
|
||||
? renderAssigneeName(key)
|
||||
: key}
|
||||
</div>
|
||||
</th>
|
||||
))
|
||||
) : (
|
||||
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
|
||||
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-brand-base">
|
||||
{barGraphData.data.map((item, index) => (
|
||||
<tr
|
||||
key={`table-row-${index}`}
|
||||
className="divide-x divide-brand-base text-xs text-brand-secondary"
|
||||
>
|
||||
<td
|
||||
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
|
||||
params.x_axis === "priority" || params.x_axis === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{params.x_axis === "priority" ? (
|
||||
getPriorityIcon(`${item.name}`)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(
|
||||
`${item.name}`,
|
||||
analytics,
|
||||
params,
|
||||
"x_axis"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{params.x_axis === "assignees__email"
|
||||
? renderAssigneeName(`${item.name}`)
|
||||
: addSpaceIfCamelCase(`${item.name}`)}
|
||||
</td>
|
||||
{params.segment ? (
|
||||
barGraphData.xAxisKeys.map((key, index) => (
|
||||
<td
|
||||
key={`segment-value-${index}`}
|
||||
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
|
||||
>
|
||||
{item[key] ?? 0}
|
||||
</td>
|
||||
))
|
||||
) : (
|
||||
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./custom-analytics";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./select";
|
||||
export * from "./project-modal";
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
import projectService from "services/project.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
|
||||
// icons
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IAnalyticsParams } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const defaultValues: IAnalyticsParams = {
|
||||
x_axis: "priority",
|
||||
y_axis: "issue_count",
|
||||
segment: null,
|
||||
project: null,
|
||||
};
|
||||
|
||||
const tabsList = ["Scope and Demand", "Custom Analytics"];
|
||||
|
||||
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
|
||||
|
||||
const params: IAnalyticsParams = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
segment: watch("segment"),
|
||||
project: projectId ? [projectId.toString()] : watch("project"),
|
||||
cycle: cycleId ? cycleId.toString() : null,
|
||||
module: moduleId ? moduleId.toString() : null,
|
||||
};
|
||||
|
||||
const { data: analytics, error: analyticsError } = useSWR(
|
||||
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? PROJECT_DETAILS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleDetails } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
cycleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleDetails } = useSWR(
|
||||
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
moduleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
|
||||
fullScreen ? "p-2 w-full" : "w-1/2"
|
||||
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-base text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm">
|
||||
<h3 className="break-all">
|
||||
Analytics for{" "}
|
||||
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreen ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group as={Fragment}>
|
||||
<Tab.List as="div" className="space-x-2 border-b border-brand-base p-5 pt-0">
|
||||
{tabsList.map((tab) => (
|
||||
<Tab
|
||||
key={tab}
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
|
||||
selected ? "bg-brand-surface-2" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<ScopeAndDemand fullScreen={fullScreen} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<CustomAnalytics
|
||||
analytics={analytics}
|
||||
analyticsError={analyticsError}
|
||||
params={params}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// icons
|
||||
import { PlayIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="space-y-3 rounded-[10px] border border-brand-base p-3">
|
||||
<h5 className="text-xs text-red-500">DEMAND</h5>
|
||||
<div>
|
||||
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
|
||||
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{defaultAnalytics?.open_issues_classified.map((group) => {
|
||||
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div key={group.state_group} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
<h6 className="capitalize">{group.state_group}</h6>
|
||||
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
|
||||
{group.state_count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-brand-secondary">{percentage}%</p>
|
||||
</div>
|
||||
<div className="bar relative h-1 w-full rounded bg-brand-surface-2">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-1 rounded duration-300"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<p className="flex items-center gap-1 text-brand-secondary">
|
||||
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
|
||||
<span>Estimate Demand:</span>
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./demand";
|
||||
export * from "./leaderboard";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./scope";
|
||||
export * from "./year-wise-issues";
|
||||
@@ -0,0 +1,52 @@
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
users: {
|
||||
avatar: string | null;
|
||||
email: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
count: number;
|
||||
}[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
<div className="p-3 border border-brand-base rounded-[10px]">
|
||||
<h6 className="text-base font-medium">{title}</h6>
|
||||
{users.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.email ?? "None"}
|
||||
className="flex items-start justify-between gap-4 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{user && user.avatar && user.avatar !== "" ? (
|
||||
<div className="rounded-full h-4 w-4 flex-shrink-0">
|
||||
<Image
|
||||
src={user.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={user.email ?? "None"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
|
||||
{user.firstName !== "" ? user.firstName[0] : "?"}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-all text-brand-secondary">
|
||||
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0">{user.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// components
|
||||
import {
|
||||
AnalyticsDemand,
|
||||
AnalyticsLeaderboard,
|
||||
AnalyticsScope,
|
||||
AnalyticsYearWiseIssues,
|
||||
} from "components/analytics";
|
||||
// ui
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// fetch-keys
|
||||
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
const params = isProjectLevel
|
||||
? {
|
||||
project: projectId ? [projectId.toString()] : null,
|
||||
cycle: cycleId ? cycleId.toString() : null,
|
||||
module: moduleId ? moduleId.toString() : null,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: defaultAnalytics,
|
||||
error: defaultAnalyticsError,
|
||||
mutate: mutateDefaultAnalytics,
|
||||
} = useSWR(
|
||||
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug
|
||||
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!defaultAnalyticsError ? (
|
||||
defaultAnalytics ? (
|
||||
<div className="h-full overflow-y-auto p-5 text-sm">
|
||||
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
|
||||
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
|
||||
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
|
||||
avatar: user?.created_by__avatar,
|
||||
email: user?.created_by__email,
|
||||
firstName: user?.created_by__first_name,
|
||||
lastName: user?.created_by__last_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
/>
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
|
||||
avatar: user?.assignees__avatar,
|
||||
email: user?.assignees__email,
|
||||
firstName: user?.assignees__first_name,
|
||||
lastName: user?.assignees__last_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
/>
|
||||
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
</Loader>
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton onClick={() => mutateDefaultAnalytics()}>Refresh</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="rounded-[10px] border border-brand-base">
|
||||
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
|
||||
<div className="divide-y divide-brand-base">
|
||||
<div>
|
||||
<h6 className="px-3 text-base font-medium">Pending issues</h6>
|
||||
{defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||
<BarGraph
|
||||
data={defaultAnalytics.pending_issue_user}
|
||||
indexBy="assignees__email"
|
||||
keys={["count"]}
|
||||
height="250px"
|
||||
colors={() => `#f97316`}
|
||||
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
|
||||
tooltip={(datum) => {
|
||||
const assignee = defaultAnalytics.pending_issue_user.find(
|
||||
(a) => a.assignees__email === `${datum.indexValue}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<span className="font-medium text-brand-secondary">
|
||||
{assignee
|
||||
? assignee.assignees__first_name + " " + assignee.assignees__last_name
|
||||
: "No assignee"}
|
||||
:{" "}
|
||||
</span>
|
||||
{datum.value}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
axisBottom={{
|
||||
renderTick: (datum) => {
|
||||
const avatar =
|
||||
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<image
|
||||
x={-8}
|
||||
y={10}
|
||||
width={16}
|
||||
height={16}
|
||||
xlinkHref={avatar}
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
},
|
||||
}}
|
||||
margin={{ top: 20 }}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">
|
||||
No matching data found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
// ui
|
||||
import { LineGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { MONTHS_LIST } from "constants/calendar";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
|
||||
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
|
||||
|
||||
return (
|
||||
<div className="py-3 border border-brand-base rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-accent))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
|
||||
if (quarterMonthsList.includes(data.month)) return data.count;
|
||||
|
||||
return 0;
|
||||
})}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./project";
|
||||
export * from "./segment";
|
||||
export * from "./x-axis";
|
||||
export * from "./y-axis";
|
||||
@@ -0,0 +1,41 @@
|
||||
// ui
|
||||
import { CustomSearchSelect } from "components/ui";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
|
||||
type Props = {
|
||||
value: string[] | null | undefined;
|
||||
onChange: (val: string[] | null) => void;
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
|
||||
const options = projects?.map((project) => ({
|
||||
value: project.id,
|
||||
query: project.name + project.identifier,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-brand-secondary text-[0.65rem]">{project.identifier}</span>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
label={
|
||||
value && value.length > 0
|
||||
? projects
|
||||
.filter((p) => value.includes(p.id))
|
||||
.map((p) => p.identifier)
|
||||
.join(", ")
|
||||
: "All projects"
|
||||
}
|
||||
optionsClassName="min-w-full"
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { IAnalyticsParams, TXAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TXAxisValues | null | undefined;
|
||||
onChange: () => void;
|
||||
params: IAnalyticsParams;
|
||||
};
|
||||
|
||||
export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<span>
|
||||
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
|
||||
<span className="text-brand-secondary">No value</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||
if (params.x_axis === item.value) return null;
|
||||
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { TXAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TXAxisValues;
|
||||
onChange: (val: string) => void;
|
||||
};
|
||||
|
||||
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { TYAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TYAxisValues;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
>
|
||||
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
@@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
description: "You are not authorized to view this page",
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image
|
||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||
@@ -36,16 +36,16 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
alt="ProjectSettingImg"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-gray-900">
|
||||
<h1 className="text-xl font-medium text-brand-base">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full text-base text-gray-500 max-w-md ">
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
{user ? (
|
||||
<p>
|
||||
You have signed in as {user.email}. <br />
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
<a className="font-medium text-brand-base">Sign in</a>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
</p>
|
||||
@@ -53,7 +53,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
<p>
|
||||
You need to{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
<a className="font-medium text-brand-base">Sign in</a>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
</p>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const JoinProject: React.FC = () => {
|
||||
project_ids: [projectId as string],
|
||||
})
|
||||
.then(async () => {
|
||||
await mutate(USER_PROJECT_VIEW(workspaceSlug.toString()));
|
||||
await mutate(USER_PROJECT_VIEW(projectId.toString()));
|
||||
setIsJoiningProject(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -41,13 +41,13 @@ export const JoinProject: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-gray-900">You are not a member of this project</h1>
|
||||
<h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-gray-500 ">
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
You are not a member of this project, but you can join this project by clicking the button
|
||||
below.
|
||||
|
||||
@@ -20,12 +20,12 @@ export const NotAWorkspaceMember = () => {
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Not Authorized!</h3>
|
||||
<p className="text-sm text-gray-500 w-1/2 mx-auto">
|
||||
<p className="mx-auto w-1/2 text-sm text-brand-secondary">
|
||||
You{"'"}re not a member of this workspace. Please contact the workspace admin to get
|
||||
an invitation or check your pending invitations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href="/invitations">
|
||||
<a>
|
||||
<SecondaryButton>Check pending invites</SecondaryButton>
|
||||
|
||||
@@ -16,7 +16,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
@@ -37,7 +37,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
||||
<>
|
||||
{link ? (
|
||||
<Link href={link}>
|
||||
<a className="border-r-2 border-gray-300 px-3 text-sm">
|
||||
<a className="border-r-2 border-brand-base px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon ?? null}
|
||||
{title}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user