Compare commits

..

3 Commits

Author SHA1 Message Date
rahulramesha 5a249f28e1 fix sorting of existing values and add sorting to all fields 2023-11-29 11:33:12 +05:30
rahulramesha 1fadcdd1f4 fix profile draft and archived issues 2023-11-28 17:43:52 +05:30
rahulramesha edd1f6e423 fix profile issue filters and kanban 2023-11-27 17:05:08 +05:30
772 changed files with 14675 additions and 24120 deletions
+46 -63
View File
@@ -6,18 +6,16 @@ on:
- closed
branches:
- master
- preview
- release
- qa
- develop
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
jobs:
branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
branch_build_and_push:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
@@ -25,6 +23,39 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
string: ${{env.TARGET_BRANCH}}
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: "/"
replace: "-"
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: "."
replace: ""
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: "_"
replace: ""
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
@@ -56,24 +87,12 @@ jobs:
!./deploy
!./web
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [branch_build_and_push]
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ce:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -93,7 +112,7 @@ jobs:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ env.FRONTEND_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
@@ -102,20 +121,8 @@ jobs:
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [branch_build_and_push]
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ce:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -135,7 +142,7 @@ jobs:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
tags: ${{ env.SPACE_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
@@ -144,20 +151,8 @@ jobs:
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [branch_build_and_push]
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ce:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -178,7 +173,7 @@ jobs:
file: ./Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ env.BACKEND_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -186,20 +181,8 @@ jobs:
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [branch_build_and_push]
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ce:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -220,7 +203,7 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64
tags: ${{ env.PROXY_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
-65
View File
@@ -1,65 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ 'develop', 'hot-fix', 'stage-release' ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'develop' ]
schedule:
- cron: '53 19 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'python', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
+107
View File
@@ -0,0 +1,107 @@
name: Update Docker Images for Plane on Release
on:
release:
types: [released, prereleased]
jobs:
build_push_backend:
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaBackend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaSpace
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaProxy
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
tags: |
type=ref,event=tag
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ steps.metaFrontend.outputs.tags }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ steps.metaBackend.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Deploy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaSpace.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.metaProxy.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+1
View File
@@ -79,6 +79,7 @@ COPY apiserver/manage.py manage.py
COPY apiserver/plane plane/
COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
+1
View File
@@ -76,6 +76,7 @@ NEXT_PUBLIC_ENABLE_OAUTH=0
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # deprecated
# Error logs
SENTRY_DSN=""
+4
View File
@@ -57,6 +57,10 @@ Setting up local environment is extremely easy and straight forward. Follow the
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
```bash
./setup.sh
```
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
Thats it!
+1 -5
View File
@@ -14,11 +14,6 @@ PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
@@ -55,6 +50,7 @@ NGINX_PORT=80
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
+1
View File
@@ -44,6 +44,7 @@ COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
COPY gunicorn.config.py ./
USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
+10 -20
View File
@@ -3,28 +3,18 @@ set -e
python manage.py wait_for_db
python manage.py migrate
# Create the default bucket
#!/bin/bash
# Set default value for ENABLE_REGISTRATION
ENABLE_REGISTRATION=${ENABLE_REGISTRATION:-1}
# Collect system information
HOSTNAME=$(hostname)
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
CPU_INFO=$(cat /proc/cpuinfo)
MEMORY_INFO=$(free -h)
DISK_INFO=$(df -h)
# Concatenate information and compute SHA-256 hash
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
# Export the variables
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
# Load the configuration variable
python manage.py configure_instance
# Check if ENABLE_REGISTRATION is not set to '0'
if [ "$ENABLE_REGISTRATION" != "0" ]; then
# Register instance
python manage.py register_instance
# Load the configuration variable
python manage.py configure_instance
fi
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
View File
+6
View File
@@ -0,0 +1,6 @@
from psycogreen.gevent import patch_psycopg
def post_fork(server, worker):
patch_psycopg()
worker.log.info("Made Psycopg2 Green")
+6 -2
View File
@@ -1,8 +1,9 @@
from django.utils import timezone
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
rate = '60/minute'
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
@@ -14,10 +15,13 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
return f'{self.scope}:{api_key}'
def allow_request(self, request, view):
# Calculate the current time as a Unix timestamp
now = timezone.now().timestamp()
# Use the parent class's method to check if the request is allowed
allowed = super().allow_request(request, view)
if allowed:
now = self.timer()
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
-5
View File
@@ -30,11 +30,6 @@ class CycleSerializer(BaseSerializer):
model = Cycle
fields = "__all__"
read_only_fields = [
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"workspace",
"project",
"owned_by",
+7 -48
View File
@@ -1,6 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
@@ -24,8 +21,7 @@ from plane.db.models import (
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@@ -46,6 +42,7 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@@ -55,10 +52,6 @@ class IssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"description",
"description_stripped",
]
def validate(self, data):
if (
@@ -67,15 +60,6 @@ class IssueSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
try:
if(data.get("description_html", None) is not None):
parsed = html.fromstring(data["description_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["description_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
# Validate assignees are from project
if data.get("assignees", []):
@@ -307,6 +291,7 @@ class IssueCommentSerializer(BaseSerializer):
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@@ -317,21 +302,6 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"comment_stripped",
"comment_json",
]
def validate(self, data):
try:
if(data.get("comment_html", None) is not None):
parsed = html.fromstring(data["comment_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["comment_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
return data
class IssueActivitySerializer(BaseSerializer):
@@ -361,23 +331,12 @@ class ModuleIssueSerializer(BaseSerializer):
]
class LabelLiteSerializer(BaseSerializer):
class Meta:
model = Label
fields = [
"id",
"name",
"color",
]
class IssueExpandSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
# Serialize the related module. It's a OneToOne relation.
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
labels = LabelLiteSerializer(read_only=True, many=True)
assignees = UserLiteSerializer(read_only=True, many=True)
state = StateLiteSerializer(read_only=True)
class Meta:
model = Issue
@@ -390,4 +349,4 @@ class IssueExpandSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
]
]
@@ -55,6 +55,7 @@ class ModuleSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
if data.get("members", []):
print(data.get("members"))
data["members"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
member_id__in=data["members"],
@@ -21,7 +21,6 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"id",
'emoji',
"workspace",
"created_at",
"updated_at",
-5
View File
@@ -16,11 +16,6 @@ class StateSerializer(BaseSerializer):
model = State
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
]
+5 -1
View File
@@ -11,6 +11,10 @@ class UserLiteSerializer(BaseSerializer):
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
read_only_fields = fields
read_only_fields = [
"id",
"is_bot",
]
+1 -1
View File
@@ -10,7 +10,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
+15 -15
View File
@@ -60,9 +60,9 @@ class InboxIssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id, issue_id=None):
if issue_id:
inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id)
def get(self, request, slug, project_id, pk=None):
if pk:
inbox_issue_queryset = self.get_queryset().get(pk=pk)
inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset,
fields=self.fields,
@@ -103,7 +103,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -163,7 +163,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
serializer = InboxIssueSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, issue_id):
def patch(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -177,14 +177,14 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
@@ -212,7 +212,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if bool(issue_data):
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
@@ -236,7 +236,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
@@ -261,7 +261,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=issue_id,
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
@@ -275,7 +275,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=issue_id,
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
@@ -297,7 +297,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
)
def delete(self, request, slug, project_id, issue_id):
def delete(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -311,14 +311,14 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
@@ -345,7 +345,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id
).delete()
inbox_issue.delete()
+1 -1
View File
@@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
@@ -99,6 +99,7 @@ class WorkspaceViewerPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__gte=10,
is_active=True,
).exists()
+45
View File
@@ -0,0 +1,45 @@
from django.utils import timezone
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get('X-Api-Key')
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f'{self.scope}:{api_key}'
def allow_request(self, request, view):
# Calculate the current time as a Unix timestamp
now = timezone.now().timestamp()
# Use the parent class's method to check if the request is allowed
allowed = super().allow_request(request, view)
if allowed:
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META['X-RateLimit-Remaining'] = max(0, available)
request.META['X-RateLimit-Reset'] = reset_time
return allowed
+10 -13
View File
@@ -103,19 +103,16 @@ class ProjectListSerializer(DynamicBaseSerializer):
members = serializers.SerializerMethodField()
def get_members(self, obj):
project_members = getattr(obj, "members_list", None)
if project_members is not None:
# Filter members by the project ID
return [
{
"id": member.id,
"member_id": member.member_id,
"member__display_name": member.member.display_name,
"member__avatar": member.member.avatar,
}
for member in project_members
]
return []
project_members = ProjectMember.objects.filter(
project_id=obj.id,
is_active=True,
).values(
"id",
"member_id",
"member__display_name",
"member__avatar",
)
return list(project_members)
class Meta:
model = Project
+11 -27
View File
@@ -26,8 +26,6 @@ class UserSerializer(BaseSerializer):
"token_updated_at",
"is_onboarded",
"is_bot",
"is_password_autoset",
"is_email_verified",
]
extra_kwargs = {"password": {"write_only": True}}
@@ -62,8 +60,6 @@ class UserMeSerializer(BaseSerializer):
"theme",
"last_workspace_id",
"use_case",
"is_password_autoset",
"is_email_verified",
]
read_only_fields = fields
@@ -84,18 +80,9 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=obj.email
).count()
if (
obj.last_workspace_id is not None
and Workspace.objects.filter(
pk=obj.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).exists()
):
if obj.last_workspace_id is not None:
workspace = Workspace.objects.filter(
pk=obj.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
pk=obj.last_workspace_id, workspace_member__member=obj.id
).first()
return {
"last_workspace_id": obj.last_workspace_id,
@@ -108,9 +95,7 @@ class UserMeSettingsSerializer(BaseSerializer):
}
else:
fallback_workspace = (
Workspace.objects.filter(
workspace_member__member_id=obj.id, workspace_member__is_active=True
)
Workspace.objects.filter(workspace_member__member_id=obj.id)
.order_by("created_at")
.first()
)
@@ -169,25 +154,24 @@ class ChangePasswordSerializer(serializers.Serializer):
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True, min_length=8)
confirm_password = serializers.CharField(required=True, min_length=8)
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
def validate(self, data):
if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError(
{"error": "New password cannot be same as old password."}
)
raise serializers.ValidationError({"error": "New password cannot be same as old password."})
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError(
{"error": "Confirm password should be same as the new password."}
)
raise serializers.ValidationError({"error": "Confirm password should be same as the new password."})
return data
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True, min_length=8)
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
+4 -38
View File
@@ -14,9 +14,9 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def create(self, validated_data):
url = validated_data.get("url", None)
def validate(self, data):
url = data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
@@ -48,42 +48,8 @@ class WebhookSerializer(DynamicBaseSerializer):
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return Webhook.objects.create(**validated_data)
def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
return data
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return super().update(instance, validated_data)
class Meta:
model = Webhook
@@ -34,7 +34,6 @@ class WorkSpaceSerializer(BaseSerializer):
"profile",
"spaces",
"workspace-invitations",
"password",
]:
raise serializers.ValidationError({"slug": "Slug is not valid"})
+16 -5
View File
@@ -5,16 +5,18 @@ from rest_framework_simplejwt.views import TokenRefreshView
from plane.app.views import (
# Authentication
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignInGenerateEndpoint,
OauthEndpoint,
EmailCheckEndpoint,
## End Authentication
# Auth Extended
ForgotPasswordEndpoint,
VerifyEmailEndpoint,
ResetPasswordEndpoint,
RequestEmailVerificationEndpoint,
ChangePasswordEndpoint,
## End Auth Extender
# API Tokens
@@ -25,15 +27,24 @@ from plane.app.views import (
urlpatterns = [
# Social Auth
path("email-check/", EmailCheckEndpoint.as_view(), name="email"),
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
# Magic Sign In/Up
path(
"magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate"
),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Email verification
path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"),
path(
"request-email-verify/",
RequestEmailVerificationEndpoint.as_view(),
name="request-reset-email",
),
# Password Manipulation
path(
"users/me/change-password/",
+6
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
@@ -43,6 +44,11 @@ urlpatterns = [
),
name="project-issue-cycle",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueGroupedEndpoint.as_view(),
name="project-issue-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueViewSet.as_view(
+12
View File
@@ -3,6 +3,8 @@ from django.urls import path
from plane.app.views import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
BulkDeleteIssuesEndpoint,
@@ -35,6 +37,16 @@ urlpatterns = [
),
name="project-issue",
),
path(
"v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListGroupedEndpoint.as_view(),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
+6
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
ModuleLinkViewSet,
ModuleFavoriteViewSet,
BulkImportModulesEndpoint,
ModuleIssueGroupedEndpoint,
)
@@ -43,6 +44,11 @@ urlpatterns = [
),
name="project-module-issues",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
ModuleIssueGroupedEndpoint.as_view(),
name="project-issue-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
ModuleIssueViewSet.as_view(
-6
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
## End User
## Workspaces
UserWorkSpacesEndpoint,
@@ -90,10 +89,5 @@ urlpatterns = [
UserWorkspaceDashboardEndpoint.as_view(),
name="user-workspace-dashboard",
),
path(
"users/me/set-password/",
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
]
+1 -1
View File
@@ -5,7 +5,7 @@ from plane.app.views import (
IssueViewViewSet,
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewFavoriteViewSet,
IssueViewFavoriteViewSet,
)
+6
View File
@@ -18,6 +18,7 @@ from plane.app.views import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
@@ -189,6 +190,11 @@ urlpatterns = [
WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"v3/workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesGroupedEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(),
+11 -8
View File
@@ -44,6 +44,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
from .state import StateViewSet
from .view import (
@@ -58,10 +59,13 @@ from .cycle import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
@@ -82,19 +86,20 @@ from .issue import (
)
from .auth_extended import (
VerifyEmailEndpoint,
RequestEmailVerificationEndpoint,
ForgotPasswordEndpoint,
ResetPasswordEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
EmailCheckEndpoint,
MagicGenerateEndpoint,
)
from .authentication import (
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
MagicSignInGenerateEndpoint,
)
from .module import (
@@ -102,6 +107,7 @@ from .module import (
ModuleIssueViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleIssueGroupedEndpoint,
)
from .api import ApiTokenEndpoint
@@ -130,6 +136,7 @@ from .page import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
CreateIssueFromBlockEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
@@ -162,8 +169,4 @@ from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
from .webhook import (
WebhookEndpoint,
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .webhook import WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint
+54 -386
View File
@@ -1,9 +1,5 @@
## Python imports
import uuid
import os
import json
import random
import string
import jwt
## Django imports
from django.contrib.auth.tokens import PasswordResetTokenGenerator
@@ -12,117 +8,80 @@ from django.utils.encoding import (
smart_bytes,
DjangoUnicodeDecodeError,
)
from django.contrib.auth.hashers import make_password
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
## Third Party Imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import permissions
from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.app.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
UserSerializer,
)
from plane.db.models import User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from plane.db.models import User
from plane.bgtasks.email_verification_task import email_verification
from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance, InstanceConfiguration
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
class RequestEmailVerificationEndpoint(BaseAPIView):
def get(self, request):
token = RefreshToken.for_user(request.user).access_token
current_site = request.META.get('HTTP_ORIGIN')
email_verification.delay(
request.user.first_name, request.user.email, token, current_site
)
return Response(
{"message": "Email sent successfully"}, status=status.HTTP_200_OK
)
def generate_magic_token(email):
key = "magic_" + str(email)
class VerifyEmailEndpoint(BaseAPIView):
def get(self, request):
token = request.GET.get("token")
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256")
user = User.objects.get(id=payload["user_id"])
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
# Initialize the redis instance
ri = redis_instance()
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return key, token, False
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
return key, token, True
def generate_password_token(user):
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
return uidb64, token
if not user.is_email_verified:
user.is_email_verified = True
user.save()
return Response(
{"email": "Successfully activated"}, status=status.HTTP_200_OK
)
except jwt.ExpiredSignatureError as _indentifier:
return Response(
{"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST
)
except jwt.exceptions.DecodeError as _indentifier:
return Response(
{"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST
)
class ForgotPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
permission_classes = [permissions.AllowAny]
def post(self, request):
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
current_site = request.META.get('HTTP_ORIGIN')
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
)
return Response(
{"message": "Check your email to reset your password"},
status=status.HTTP_200_OK,
@@ -133,40 +92,30 @@ class ForgotPasswordEndpoint(BaseAPIView):
class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
permission_classes = [permissions.AllowAny]
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
return Response(
{"error": "Token is invalid"},
{"error": "token is not valid, please check the new one"},
status=status.HTTP_401_UNAUTHORIZED,
)
# Reset the password
serializer = ResetPasswordSerializer(data=request.data)
if serializer.is_valid():
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
# Log the user in
# Generate access token for the user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
response = {
"status": "success",
"code": status.HTTP_200_OK,
"message": "Password updated successfully",
}
return Response(data, status=status.HTTP_200_OK)
return Response(response)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except DjangoUnicodeDecodeError as indentifier:
@@ -179,6 +128,7 @@ class ResetPasswordEndpoint(BaseAPIView):
class ChangePasswordEndpoint(BaseAPIView):
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id)
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
@@ -188,288 +138,6 @@ class ChangePasswordEndpoint(BaseAPIView):
)
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
return Response(
{"message": "Password updated successfully"}, status=status.HTTP_200_OK
)
return Response({"message": "Password updated successfully"}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SetUserPasswordEndpoint(BaseAPIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
return Response(
{
"error": "Your password is already set please change your password from profile"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password and len(str(password)) < 8:
return Response(
{"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Set the user password
user.set_password(password)
user.is_password_autoset = False
user.save()
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
class MagicGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up the email
email = email.strip().lower()
validate_email(email)
# check if the email exists not
if not User.objects.filter(email=email).exists():
# Create a user
_ = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# If the smtp is configured send through here
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class EmailCheckEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False)
if not email:
return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
)
# validate the email
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Check if the user exists
user = User.objects.filter(email=email).first()
current_site = request.META.get("HTTP_ORIGIN")
# If new user
if user is None:
# Create the user
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the user with default values
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
if not bool(
get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response(
{"is_password_autoset": user.is_password_autoset, "is_existing": False},
status=status.HTTP_200_OK,
)
# Existing user
else:
if user.is_password_autoset:
## Generate a random token
if not bool(
get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
)
# Generate magic token
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, key, token, current_site)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else:
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
)
# User should enter password to login
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
+237 -107
View File
@@ -1,7 +1,11 @@
# Python imports
import os
import uuid
import random
import string
import json
import requests
from requests.exceptions import RequestException
# Django imports
from django.utils import timezone
@@ -15,7 +19,8 @@ from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_message
from sentry_sdk import capture_exception, capture_message
# Module imports
from . import BaseAPIView
@@ -27,11 +32,11 @@ from plane.db.models import (
ProjectMember,
)
from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
@@ -44,18 +49,27 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{"error": "Instance is not configured"},
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
@@ -63,8 +77,8 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@@ -73,25 +87,6 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# If the sign up is not enabled and the user does not have invite disallow him from creating the account
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user already exists
if User.objects.filter(email=email).exists():
return Response(
@@ -103,7 +98,6 @@ class SignUpEndpoint(BaseAPIView):
user.set_password(password)
# settings last actives for the user
user.is_password_autoset = False
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -111,13 +105,81 @@ class SignUpEndpoint(BaseAPIView):
user.token_updated_at = timezone.now()
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=True
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
@@ -125,14 +187,6 @@ class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
@@ -143,8 +197,8 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@@ -153,48 +207,23 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
if user is None:
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Create the user
else:
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value")
# Create the user
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
# Sign up Process
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last active for the user
@@ -263,7 +292,7 @@ class SignInEndpoint(BaseAPIView):
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
@@ -272,7 +301,7 @@ class SignInEndpoint(BaseAPIView):
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -306,20 +335,101 @@ class SignOutEndpoint(BaseAPIView):
return Response({"message": "success"}, status=status.HTTP_200_OK)
class MagicSignInGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class MagicSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False).strip().lower()
@@ -338,28 +448,48 @@ class MagicSignInEndpoint(BaseAPIView):
email = data["email"]
if str(token) == str(user_token):
user = User.objects.get(email=email)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False
)
else:
user = User.objects.create(
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True
)
user.is_active = True
user.is_email_verified = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
+1 -3
View File
@@ -11,7 +11,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration
from plane.license.models import Instance, InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@@ -104,6 +104,4 @@ class ConfigurationEndpoint(BaseAPIView):
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
return Response(data, status=status.HTTP_200_OK)
+70 -4
View File
@@ -539,8 +539,9 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -575,9 +576,24 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
@@ -693,6 +709,56 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, cycle_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_cycle__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.filter(**filters)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
+137 -13
View File
@@ -4,7 +4,6 @@ import random
from itertools import chain
# Django imports
from django.db import models
from django.utils import timezone
from django.db.models import (
Prefetch,
@@ -133,7 +132,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
@@ -217,9 +215,25 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -297,6 +311,104 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
serializer = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueListGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
@@ -971,7 +1083,6 @@ class IssueArchiveViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@@ -1062,9 +1173,14 @@ class IssueArchiveViewSet(BaseViewSet):
else issue_queryset.filter(parent__isnull=True)
)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
@@ -1464,7 +1580,6 @@ class IssueDraftViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
@@ -1547,9 +1662,18 @@ class IssueDraftViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
+69 -4
View File
@@ -324,8 +324,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
@@ -359,9 +360,24 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
@@ -466,6 +482,55 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_module__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.filter(**filters)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class ModuleLinkViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
+38 -31
View File
@@ -2,6 +2,7 @@
import uuid
import requests
import os
from requests.exceptions import RequestException
# Django imports
from django.utils import timezone
@@ -30,7 +31,7 @@ from plane.db.models import (
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration, Instance
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@@ -135,14 +136,6 @@ class OauthEndpoint(BaseAPIView):
def post(self, request):
try:
# Check if instance is registered or not
instance = Instance.objects.first()
if instance is None and not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
medium = request.data.get("medium", False)
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
@@ -150,17 +143,34 @@ class OauthEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
if not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
) or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
if (
(
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
)
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{"error": "Github or Google login is not configured"},
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -276,8 +286,8 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
# Send event
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
@@ -285,8 +295,8 @@ class OauthEndpoint(BaseAPIView):
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False,
medium=medium.upper(),
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -299,18 +309,15 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist:
## Signup Case
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
email=request.user.email
).exists()
):
return Response(
@@ -334,7 +341,7 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
user = User(
username=username,
email=email,
mobile_number=mobile_number,
@@ -345,6 +352,7 @@ class OauthEndpoint(BaseAPIView):
)
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -410,7 +418,7 @@ class OauthEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
@@ -419,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True,
first_time=True
)
SocialLoginConnection.objects.update_or_create(
@@ -437,5 +445,4 @@ class OauthEndpoint(BaseAPIView):
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_201_CREATED)
+48 -30
View File
@@ -22,7 +22,6 @@ from plane.db.models import (
IssueAssignee,
IssueActivity,
PageLog,
ProjectMember,
)
from plane.app.serializers import (
PageSerializer,
@@ -141,6 +140,12 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can lock the page
if request.user.id != page.owned_by_id:
return Response(
{"error": "Only the page owner can lock the page"},
)
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -150,6 +155,12 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can unlock the page
if request.user.id != page.owned_by_id:
return Response(
{"error": "Only the page owner can unlock the page"},
status=status.HTTP_400_BAD_REQUEST,
)
page.is_locked = False
page.save()
@@ -164,16 +175,10 @@ class PageViewSet(BaseViewSet):
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
# only the owner and admin can archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
if page.owned_by_id != request.user.id:
return Response(
{"error": "Only the owner and admin can archive the page"},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Only the owner of the page can archive a page"},
status=status.HTTP_204_NO_CONTENT,
)
unarchive_archive_page_and_descendants(page_id, datetime.now())
@@ -183,15 +188,9 @@ class PageViewSet(BaseViewSet):
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
# only the owner and admin can un archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
if page.owned_by_id != request.user.id:
return Response(
{"error": "Only the owner and admin can un archive the page"},
{"error": "Only the owner of the page can unarchive a page"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -217,18 +216,6 @@ class PageViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
# only the owner and admin can delete the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can delete the page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
@@ -240,6 +227,7 @@ class PageViewSet(BaseViewSet):
parent_id=pk, project_id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -322,6 +310,36 @@ class PageLogEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class CreateIssueFromBlockEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id, page_id):
page = Page.objects.get(
workspace__slug=slug,
project_id=project_id,
pk=page_id,
)
issue = Issue.objects.create(
name=request.data.get("name"),
project_id=project_id,
)
_ = IssueAssignee.objects.create(
issue=issue, assignee=request.user, project_id=project_id
)
_ = IssueActivity.objects.create(
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"created the issue from {page.name} block",
verb="created",
)
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
class SubPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
-1
View File
@@ -165,7 +165,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace__slug=slug,
is_active=True,
).select_related("member"),
to_attr='members_list'
)
)
.order_by("sort_order", "name")
+1 -1
View File
@@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
+5 -14
View File
@@ -17,7 +17,7 @@ from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, IntegerField
from django.db.models import Q, F, Count, Case, When, Value, IntegerField
class UserEndpoint(BaseViewSet):
@@ -52,6 +52,7 @@ class UserEndpoint(BaseViewSet):
projects_to_deactivate = []
workspaces_to_deactivate = []
projects = ProjectMember.objects.filter(
member=request.user, is_active=True
).annotate(
@@ -112,15 +113,6 @@ class UserEndpoint(BaseViewSet):
# Deactivate the user
user.is_active = False
user.last_workspace_id = None
user.is_tour_completed = False
user.is_onboarded = False
user.onboarding_step = {
"workspace_join": False,
"profile_complete": False,
"workspace_create": False,
"workspace_invite": False,
}
user.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -143,9 +135,9 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace", "issue", "project"
)
queryset = IssueActivity.objects.filter(
actor=request.user
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
request=request,
@@ -154,4 +146,3 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)
+19 -8
View File
@@ -20,7 +20,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView
from . import BaseViewSet
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
@@ -95,7 +95,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
@@ -178,13 +177,25 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
+80 -37
View File
@@ -73,7 +73,8 @@ from plane.app.permissions import (
)
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.utils.grouper import group_results
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
@@ -112,10 +113,7 @@ class WorkSpaceViewSet(BaseViewSet):
return (
self.filter_queryset(super().get_queryset().select_related("owner"))
.order_by("name")
.filter(
workspace_member__member=self.request.user,
workspace_member__is_active=True,
)
.filter(workspace_member__member=self.request.user)
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.select_related("owner")
@@ -191,21 +189,17 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
workspace = (
Workspace.objects.prefetch_related(
Prefetch(
"workspace_member",
queryset=WorkspaceMember.objects.filter(
member=request.user, is_active=True
),
(
Workspace.objects.prefetch_related(
Prefetch("workspace_member", queryset=WorkspaceMember.objects.all())
)
.filter(
workspace_member__member=request.user,
)
.select_related("owner")
)
.select_related("owner")
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.filter(
workspace_member__member=request.user, workspace_member__is_active=True
)
.distinct()
)
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
@@ -325,7 +319,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
workspace_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get("HTTP_ORIGIN")
current_site = request.META.get('HTTP_ORIGIN')
# Send invitations
for invitation in workspace_invitations:
@@ -406,17 +400,6 @@ class WorkspaceJoinEndpoint(BaseAPIView):
# Delete the invitation
workspace_invite.delete()
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
workspace_invite_event.delay(
user=user.id if user is not None else None,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="MEMBER_ACCEPTED",
accepted_from="EMAIL",
)
return Response(
{"message": "Workspace Invitation Accepted"},
@@ -435,9 +418,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
)
def get(self, request, slug, pk):
workspace_invitation = WorkspaceMemberInvite.objects.get(
workspace__slug=slug, pk=pk
)
workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk)
serializer = WorkSpaceMemberInviteSerializer(workspace_invitation)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1217,7 +1198,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
]
def get(self, request, slug, user_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
@@ -1319,12 +1299,75 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceUserProfileIssuesGroupedEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
+35 -26
View File
@@ -1,8 +1,7 @@
# Python imports
import csv
import io
import requests
import json
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -18,8 +17,8 @@ from sentry_sdk import capture_exception
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
row_mapping = {
"state__name": "State",
@@ -44,7 +43,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
MODULE_ID = "issue_module__module_id"
def send_export_email(email, slug, csv_buffer, rows):
def send_export_email(email, slug, csv_buffer):
"""Helper function to send export email."""
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
@@ -56,34 +55,47 @@ def send_export_email(email, slug, csv_buffer, rows):
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)
return
def get_assignee_details(slug, filters):
@@ -451,11 +463,8 @@ def analytic_export_task(email, data, slug):
)
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows)
return
send_export_email(email, slug, csv_buffer)
except Exception as e:
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -0,0 +1,90 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
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.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink
subject = "Verify your Email!"
context = {
"first_name": first_name,
"verification_url": abs_url,
}
html_content = render_to_string("emails/auth/email_verification.html", context)
text_content = strip_tags(html_content)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
+1 -20
View File
@@ -10,6 +10,7 @@ from sentry_sdk import capture_exception
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
print(user, email, user_agent, ip, event_name, medium, first_time)
try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
@@ -26,25 +27,5 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
"first_time": first_time
}
)
except Exception as e:
capture_exception(e)
@shared_task
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"accepted_from": accepted_from
}
)
except Exception as e:
capture_exception(e)
+37 -33
View File
@@ -1,7 +1,5 @@
# Python import
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -14,58 +12,64 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def forgot_password(first_name, email, uidb64, token, current_site):
try:
relative_link = (
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}"
)
abs_url = str(current_site) + relative_link
realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
subject = "A new password to your Plane account has been requested"
subject = "Reset Your Password - Plane"
context = {
"first_name": first_name,
"forgot_password_url": abs_url,
"email": email,
}
html_content = render_to_string("emails/auth/forgot_password.html", context)
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
+43 -28
View File
@@ -1,7 +1,5 @@
# Python imports
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -14,45 +12,63 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def magic_link(email, key, token, current_site):
try:
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = current_site + realtivelink
subject = "Login for Plane"
context = {"magic_url": abs_url, "code": token}
html_content = render_to_string("emails/auth/magic_signin.html", context)
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the mail
subject = f"Your unique Plane login code is {token}"
context = {"code": token, "email": email}
html_content = render_to_string("emails/auth/magic_signin.html", context)
text_content = strip_tags(html_content)
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
@@ -60,7 +76,6 @@ def magic_link(email, key, token, current_site):
msg.send()
return
except Exception as e:
print(e)
capture_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
@@ -14,7 +14,7 @@ from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_email_configuration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
@@ -48,27 +48,42 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
+11 -10
View File
@@ -90,6 +90,17 @@ def webhook_task(self, webhook, slug, event, event_data, action):
else None
)
# Use HMAC for generating signature
if webhook.secret_key:
event_data_json = json.dumps(event_data) if event_data is not None else "{}"
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
event_data_json.encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
action = {
"POST": "create",
"PATCH": "update",
@@ -105,16 +116,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"data": event_data,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(
webhook.url,
@@ -1,7 +1,5 @@
# Python imports
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -17,8 +15,8 @@ from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
@@ -35,29 +33,16 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
# The complete url including the domain
abs_url = str(current_site) + relative_link
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
abs_url = current_site + relative_link
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane"
context = {
"email": email,
"first_name": user.first_name or user.display_name or user.email,
"first_name": invitor,
"workspace_name": workspace.name,
"abs_url": abs_url,
"invitation_url": abs_url,
}
html_content = render_to_string(
@@ -73,17 +58,41 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
@@ -103,7 +112,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
print("Workspace or WorkspaceMember Invite Does not exists")
return
except Exception as e:
# Print logs if in DEBUG mode
+1 -5
View File
@@ -28,13 +28,9 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
"schedule": crontab(hour=0, minute=0),
},
"check-instance-verification": {
"task": "plane.license.bgtasks.instance_verification_task.instance_verification_task",
"schedule": crontab(minute=0, hour='*/4'),
},
}
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
@@ -1,54 +0,0 @@
# Python imports
import getpass
# Django imports
from django.core.management import BaseCommand
# Module imports
from plane.db.models import User
class Command(BaseCommand):
help = "Reset password of the user with the given email"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("email", type=str, help="user email")
def handle(self, *args, **options):
# get the user email from console
email = options.get("email", False)
# raise error if email is not present
if not email:
self.stderr.write("Error: Email is required")
return
# filter the user
user = User.objects.filter(email=email).first()
# Raise error if the user is not present
if not user:
self.stderr.write(f"Error: User with {email} does not exists")
return
# get password for the user
password = getpass.getpass("Password: ")
confirm_password = getpass.getpass("Password (again): ")
# If the passwords doesn't match raise error
if password != confirm_password:
self.stderr.write("Error: Your passwords didn't match.")
return
# Blank passwords should not be allowed
if password.strip() == "":
self.stderr.write("Error: Blank passwords aren't allowed.")
return
# Set user password
user.set_password(password)
user.is_password_autoset = False
user.save()
self.stdout.write(self.style.SUCCESS(f"User password updated succesfully"))
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('transaction', models.UUIDField(default=uuid.uuid4)),
('entity_identifier', models.UUIDField(null=True)),
('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('page_mention', 'Page Mention'), ('user_mention', 'User Mention')], max_length=30, verbose_name='Transaction Type')),
('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('mention', 'Mention')], max_length=30, verbose_name='Transaction Type')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
@@ -1,11 +1,6 @@
# Generated by Django 4.2.5 on 2023-11-17 08:48
from django.db import migrations, models
import plane.db.models.workspace
def user_password_autoset(apps, schema_editor):
User = apps.get_model("db", "User")
User.objects.update(is_password_autoset=True)
class Migration(migrations.Migration):
@@ -25,15 +20,4 @@ class Migration(migrations.Migration):
name='organization_size',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='fileasset',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='workspace',
name='slug',
field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]),
),
migrations.RunPython(user_password_autoset),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2023-11-20 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0050_user_use_case_alter_workspace_organization_size'),
]
operations = [
migrations.AddField(
model_name='fileasset',
name='is_deleted',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.5 on 2023-11-23 14:57
from django.db import migrations, models
import plane.db.models.workspace
class Migration(migrations.Migration):
dependencies = [
('db', '0051_fileasset_is_deleted'),
]
operations = [
migrations.AlterField(
model_name='workspace',
name='slug',
field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]),
),
]
+2 -3
View File
@@ -7,6 +7,7 @@ from django.db import models
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
@@ -140,10 +141,8 @@ class Issue(ProjectBaseModel):
)["largest"]
# aggregate can return None! Check it first.
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
if last_id:
if last_id is not None:
self.sequence_id = last_id + 1
else:
self.sequence_id = 1
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
+1 -2
View File
@@ -57,8 +57,7 @@ class PageLog(ProjectBaseModel):
("module", "Module"),
("back_link", "Back Link"),
("forward_link", "Forward Link"),
("page_mention", "Page Mention"),
("user_mention", "User Mention"),
("mention", "Mention"),
)
transaction = models.UUIDField(default=uuid.uuid4)
page = models.ForeignKey(
@@ -1 +1 @@
from .instance import InstanceAdminPermission
from .instance import InstanceOwnerPermission, InstanceAdminPermission
@@ -5,6 +5,20 @@ from rest_framework.permissions import BasePermission
from plane.license.models import Instance, InstanceAdmin
class InstanceOwnerPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
instance = Instance.objects.first()
return InstanceAdmin.objects.filter(
role=20,
instance=instance,
user=request.user,
).exists()
class InstanceAdminPermission(BasePermission):
def has_permission(self, request, view):
@@ -2,7 +2,7 @@
from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration
from plane.app.serializers import BaseSerializer
from plane.app.serializers import UserAdminLiteSerializer
from plane.license.utils.encryption import decrypt_data
class InstanceSerializer(BaseSerializer):
primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True)
@@ -12,13 +12,14 @@ class InstanceSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"id",
"primary_owner",
"primary_email",
"instance_id",
"license_key",
"api_key",
"version",
"email",
"last_checked_at",
"is_setup_done",
]
@@ -39,11 +40,3 @@ class InstanceConfigurationSerializer(BaseSerializer):
class Meta:
model = InstanceConfiguration
fields = "__all__"
def to_representation(self, instance):
data = super().to_representation(instance)
# Decrypt secrets value
if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value)
return data
@@ -1,7 +1,6 @@
from .instance import (
InstanceEndpoint,
TransferPrimaryOwnerEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
+132 -150
View File
@@ -2,22 +2,13 @@
import json
import os
import requests
import uuid
import random
import string
# Django imports
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
# Module imports
from plane.app.views import BaseAPIView
@@ -28,35 +19,102 @@ from plane.license.api.serializers import (
InstanceConfigurationSerializer,
)
from plane.license.api.permissions import (
InstanceOwnerPermission,
InstanceAdminPermission,
)
from plane.db.models import User
from plane.license.utils.encryption import encrypt_data
class InstanceEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method == "PATCH":
return [
InstanceAdminPermission(),
if self.request.method in ["POST", "PATCH"]:
self.permission_classes = [
InstanceOwnerPermission,
]
return [
AllowAny(),
]
else:
self.permission_classes = [
InstanceAdminPermission,
]
return super(InstanceEndpoint, self).get_permissions()
def post(self, request):
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then register this instance
if instance is None:
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
if not license_engine_base_url:
raise Response(
{"error": "LICENSE_ENGINE_BASE_URL is required"},
status=status.HTTP_400_BAD_REQUEST,
)
headers = {"Content-Type": "application/json"}
payload = {
"email": request.user.email,
"version": data.get("version", 0.1),
}
response = requests.post(
f"{license_engine_base_url}/api/instances",
headers=headers,
data=json.dumps(payload),
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
primary_email=data.get("email"),
primary_owner=request.user,
last_checked_at=timezone.now(),
)
# Create instance admin
_ = InstanceAdmin.objects.create(
user=request.user,
instance=instance,
role=20,
)
return Response(
{
"message": f"Instance succesfully registered with owner: {instance.primary_owner.email}"
},
status=status.HTTP_201_CREATED,
)
return Response(
{"error": "Instance could not be registered"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{
"message": f"Instance already registered with instance owner: {instance.primary_owner.email}"
},
status=status.HTTP_200_OK,
)
def get(self, request):
instance = Instance.objects.first()
# get the instance
if instance is None:
return Response(
{"is_activated": False, "is_setup_done": False},
status=status.HTTP_200_OK,
)
return Response({"activated": False}, status=status.HTTP_400_BAD_REQUEST)
# Return instance
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
return Response(data, status=status.HTTP_200_OK)
serializer.data["activated"] = True
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request):
# Get the instance
@@ -68,15 +126,58 @@ class InstanceEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class InstanceAdminEndpoint(BaseAPIView):
class TransferPrimaryOwnerEndpoint(BaseAPIView):
permission_classes = [
InstanceAdminPermission,
InstanceOwnerPermission,
]
# Transfer the owner of the instance
def post(self, request):
instance = Instance.objects.first()
# Get the email of the new user
email = request.data.get("email", False)
if not email:
return Response(
{"error": "User is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Get users
user = User.objects.get(email=email)
# Save the instance user
instance.primary_owner = user
instance.primary_email = user.email
instance.save(update_fields=["owner", "email"])
# Add the user to admin
_ = InstanceAdmin.objects.get_or_create(
instance=instance,
user=user,
role=20,
)
return Response(
{"message": "Owner successfully updated"}, status=status.HTTP_200_OK
)
class InstanceAdminEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method in ["POST", "DELETE"]:
self.permission_classes = [
InstanceOwnerPermission,
]
else:
self.permission_classes = [
InstanceAdminPermission,
]
return super(InstanceAdminEndpoint, self).get_permissions()
# Create an instance admin
def post(self, request):
email = request.data.get("email", False)
role = request.data.get("role", 20)
role = request.data.get("role", 15)
if not email:
return Response(
@@ -129,137 +230,18 @@ class InstanceConfigurationEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request):
configurations = InstanceConfiguration.objects.filter(
key__in=request.data.keys()
)
configurations = InstanceConfiguration.objects.filter(key__in=request.data.keys())
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
configuration.value = value
configuration.value = request.data.get(configuration.key, configuration.value)
bulk_configurations.append(configuration)
InstanceConfiguration.objects.bulk_update(
bulk_configurations, ["value"], batch_size=100
bulk_configurations,
["value"],
batch_size=100
)
serializer = InstanceConfigurationSerializer(configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
class InstanceAdminSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
# check if the instance is already activated
if InstanceAdmin.objects.first():
return Response(
{"error": "Admin for this instance is already registered"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the email and password from all the user
email = request.data.get("email", False)
password = request.data.get("password", False)
# return error if the email and password is not present
if not email or not password:
return Response(
{"error": "Email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already a user exists or not
user = User.objects.filter(email=email).first()
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
else:
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Register the user as an instance admin
_ = InstanceAdmin.objects.create(
user=user,
instance=instance,
)
# Make the setup flag True
instance.is_setup_done = True
instance.save()
# get tokens for user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignUpScreenVisitedEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
instance.is_signup_screen_visited = True
instance.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1,136 +0,0 @@
# Python imports
import os
import json
import requests
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import User
from plane.license.models import Instance, InstanceAdmin
def instance_verification(instance):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"),
"user_count": User.objects.filter(is_bot=False).count(),
}
# Register the instance
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
# check response status
if response.status_code == 201:
data = response.json()
# Update instance
instance.instance_id = data.get("id")
instance.license_key = data.get("license_key")
instance.api_key = data.get("api_key")
instance.version = data.get("version")
instance.user_count = data.get("user_count", 0)
instance.is_verified = True
instance.save()
else:
return
def admin_verification(instance):
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Get all the unverified instance admins
instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related(
"user"
)
updated_instance_admin = []
# Verify the instance admin
for instance_admin in instance_admins:
instance_admin.is_verified = True
# Create the admin
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers,
data=json.dumps(
{
"email": str(instance_admin.user.email),
"signup_mode": "EMAIL",
"is_admin": True,
}
),
timeout=30,
)
updated_instance_admin.append(instance_admin)
# update all the instance admins
InstanceAdmin.objects.bulk_update(
updated_instance_admin, ["is_verified"], batch_size=10
)
return
def instance_user_count(instance):
try:
instance_users = User.objects.filter(is_bot=False).count()
# Update the count in the license engine
payload = {
"user_count": instance_users,
}
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
return
except requests.RequestException:
return
@shared_task
def instance_verification_task():
try:
# Get the first instance
instance = Instance.objects.first()
# Only register instance if it is not verified
if not instance.is_verified:
instance_verification(instance=instance)
# Admin verifications
admin_verification(instance=instance)
# Update user count
instance_user_count(instance=instance)
return
except requests.RequestException:
return
@@ -2,131 +2,48 @@
import os
# Django imports
from django.core.management.base import BaseCommand
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
# Module imports
from plane.license.models import InstanceConfiguration
class Command(BaseCommand):
help = "Configure instance variables"
def handle(self, *args, **options):
from plane.license.utils.encryption import encrypt_data
config_keys = [
config_keys = {
# Authentication Settings
{
"key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_SECRET",
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_PASSWORD",
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"category": "SMTP",
"is_encrypted": True,
},
{
"key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "OPENAI_API_KEY",
"value": os.environ.get("OPENAI_API_KEY"),
"category": "OPENAI",
"is_encrypted": True,
},
{
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
]
"GOOGLE_CLIENT_ID": os.environ.get("GOOGLE_CLIENT_ID"),
"GOOGLE_CLIENT_SECRET": os.environ.get("GOOGLE_CLIENT_SECRET"),
"GITHUB_CLIENT_ID": os.environ.get("GITHUB_CLIENT_ID"),
"GITHUB_CLIENT_SECRET": os.environ.get("GITHUB_CLIENT_SECRET"),
"ENABLE_SIGNUP": os.environ.get("ENABLE_SIGNUP", "1"),
"ENABLE_EMAIL_PASSWORD": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"ENABLE_MAGIC_LINK_LOGIN": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
# Email Settings
"EMAIL_HOST": os.environ.get("EMAIL_HOST", ""),
"EMAIL_HOST_USER": os.environ.get("EMAIL_HOST_USER", ""),
"EMAIL_HOST_PASSWORD": os.environ.get("EMAIL_HOST_PASSWORD"),
"EMAIL_PORT": os.environ.get("EMAIL_PORT", "587"),
"EMAIL_FROM": os.environ.get("EMAIL_FROM", ""),
"EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"),
"EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"),
# Open AI Settings
"OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""),
"GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
# Unsplash Access Key
"UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "")
}
for item in config_keys:
for key, value in config_keys.items():
obj, created = InstanceConfiguration.objects.get_or_create(
key=item.get("key")
key=key
)
if created:
obj.category = item.get("category")
obj.is_encrypted = item.get("is_encrypted", False)
if item.get("is_encrypted", False):
obj.value = encrypt_data(item.get("value"))
else:
obj.value = item.get("value")
obj.value = value
obj.save()
self.stdout.write(
self.style.SUCCESS(
f"{obj.key} loaded with value from environment variable."
)
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
else:
self.stdout.write(
self.style.WARNING(f"{obj.key} configuration already exists")
)
self.stdout.write(self.style.WARNING(f"{key} configuration already exists"))
@@ -1,25 +1,23 @@
# Python imports
import json
import os
import requests
import secrets
import uuid
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
# Module imports
from plane.license.models import Instance
from plane.db.models import User
from plane.license.models import Instance, InstanceAdmin
class Command(BaseCommand):
help = "Check if instance in registered else register"
def add_arguments(self, parser):
# Positional argument
parser.add_argument('machine_signature', type=str, help='Machine signature')
def handle(self, *args, **options):
# Check if the instance is registered
instance = Instance.objects.first()
@@ -30,61 +28,67 @@ class Command(BaseCommand):
# Load JSON content from the file
data = json.load(file)
machine_signature = options.get("machine_signature", False)
if not machine_signature:
raise CommandError("Machine signature is required")
# Check if machine is online
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": machine_signature,
"user_count": User.objects.filter(is_bot=False).count(),
}
admin_email = os.environ.get("ADMIN_EMAIL")
try:
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30
)
validate_email(admin_email)
except ValidationError:
CommandError(f"{admin_email} is not a valid ADMIN_EMAIL")
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=data.get("user_count", 0),
is_verified=True,
)
# Raise an exception if the admin email is not provided
if not admin_email:
raise CommandError("ADMIN_EMAIL is required")
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered and verified"
)
)
return
except requests.RequestException as _e:
# Check if the admin email user exists
user = User.objects.filter(email=admin_email).first()
# If the user does not exist create the user and add him to the database
if user is None:
user = User.objects.create(email=admin_email, username=uuid.uuid4().hex)
user.set_password(admin_email)
user.save()
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
if not license_engine_base_url:
raise CommandError("LICENSE_ENGINE_BASE_URL is required")
headers = {"Content-Type": "application/json"}
payload = {
"email": user.email,
"version": data.get("version", 0.1),
}
response = requests.post(
f"{license_engine_base_url}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
version=payload.get("version"),
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
primary_email=data.get("email"),
primary_owner=user,
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
)
# Create instance admin
_ = InstanceAdmin.objects.create(
user=user,
instance=instance,
role=20,
)
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered"
f"Instance successfully registered with owner: {instance.primary_owner.email}"
)
)
return
@@ -92,7 +96,7 @@ class Command(BaseCommand):
else:
self.stdout.write(
self.style.SUCCESS(
f"Instance already registered"
f"Instance already registered with instance owner: {instance.primary_owner.email}"
)
)
return
@@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2023-12-06 06:49
# Generated by Django 4.2.5 on 2023-11-15 14:22
from django.conf import settings
from django.db import migrations, models
@@ -27,15 +27,13 @@ class Migration(migrations.Migration):
('license_key', models.CharField(blank=True, max_length=256, null=True)),
('api_key', models.CharField(max_length=16)),
('version', models.CharField(max_length=10)),
('primary_email', models.CharField(max_length=256)),
('last_checked_at', models.DateTimeField()),
('namespace', models.CharField(blank=True, max_length=50, null=True)),
('is_telemetry_enabled', models.BooleanField(default=True)),
('is_support_required', models.BooleanField(default=True)),
('is_setup_done', models.BooleanField(default=False)),
('is_signup_screen_visited', models.BooleanField(default=False)),
('user_count', models.PositiveBigIntegerField(default=0)),
('is_verified', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('primary_owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_primary_owner', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
options={
@@ -53,8 +51,6 @@ class Migration(migrations.Migration):
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('key', models.CharField(max_length=100, unique=True)),
('value', models.TextField(blank=True, default=None, null=True)),
('category', models.TextField()),
('is_encrypted', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
@@ -71,8 +67,7 @@ class Migration(migrations.Migration):
('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)),
('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)),
('is_verified', models.BooleanField(default=False)),
('role', models.PositiveIntegerField(choices=[(20, 'Owner'), (15, 'Admin')], default=15)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
@@ -83,7 +78,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Instance Admins',
'db_table': 'instance_admins',
'ordering': ('-created_at',),
'unique_together': {('instance', 'user')},
},
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.5 on 2023-11-16 09:45
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('license', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='instanceadmin',
unique_together={('instance', 'user')},
),
]
+12 -12
View File
@@ -4,9 +4,11 @@ from django.conf import settings
# Module imports
from plane.db.models import BaseModel
from plane.db.mixins import AuditModel
ROLE_CHOICES = (
(20, "Admin"),
(20, "Owner"),
(15, "Admin"),
)
@@ -18,19 +20,20 @@ class Instance(BaseModel):
license_key = models.CharField(max_length=256, null=True, blank=True)
api_key = models.CharField(max_length=16)
version = models.CharField(max_length=10)
# User information
primary_email = models.CharField(max_length=256)
primary_owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="instance_primary_owner",
)
# Instnace specifics
last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=50, blank=True, null=True)
# telemetry and support
is_telemetry_enabled = models.BooleanField(default=True)
is_support_required = models.BooleanField(default=True)
# is setup done
is_setup_done = models.BooleanField(default=False)
# signup screen
is_signup_screen_visited = models.BooleanField(default=False)
# users
user_count = models.PositiveBigIntegerField(default=0)
is_verified = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
@@ -47,8 +50,7 @@ class InstanceAdmin(BaseModel):
related_name="instance_owner",
)
instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins")
role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20)
is_verified = models.BooleanField(default=False)
role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=15)
class Meta:
unique_together = ["instance", "user"]
@@ -62,8 +64,6 @@ class InstanceConfiguration(BaseModel):
# The instance configuration variables
key = models.CharField(max_length=100, unique=True)
value = models.TextField(null=True, blank=True, default=None)
category = models.TextField()
is_encrypted = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance Configuration"
+6 -12
View File
@@ -2,10 +2,9 @@ from django.urls import path
from plane.license.api.views import (
InstanceEndpoint,
TransferPrimaryOwnerEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
urlpatterns = [
@@ -14,6 +13,11 @@ urlpatterns = [
InstanceEndpoint.as_view(),
name="instance",
),
path(
"instances/transfer-primary-owner/",
TransferPrimaryOwnerEndpoint.as_view(),
name="instance",
),
path(
"instances/admins/",
InstanceAdminEndpoint.as_view(),
@@ -29,14 +33,4 @@ urlpatterns = [
InstanceConfigurationEndpoint.as_view(),
name="instance-configuration",
),
path(
"instances/admins/sign-in/",
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"instances/admins/sign-up-screen-visited/",
SignUpScreenVisitedEndpoint.as_view(),
name="instance-sign-up",
),
]
@@ -1,22 +0,0 @@
import base64
import hashlib
from django.conf import settings
from cryptography.fernet import Fernet
def derive_key(secret_key):
# Use a key derivation function to get a suitable encryption key
dk = hashlib.pbkdf2_hmac('sha256', secret_key.encode(), b'salt', 100000)
return base64.urlsafe_b64encode(dk)
def encrypt_data(data):
cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
encrypted_data = cipher_suite.encrypt(data.encode())
return encrypted_data.decode() # Convert bytes to string
def decrypt_data(encrypted_data):
cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes
return decrypted_data.decode()
@@ -1,63 +1,6 @@
import os
# Helper function to return value from the passed key
def get_configuration_value(query, key, default=None):
for item in query:
if item["key"] == key:
if item['key'] == key:
return item.get("value", default)
return default
def get_email_configuration(instance_configuration):
# Get the configuration variables
EMAIL_HOST_USER = get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER", None),
)
EMAIL_HOST_PASSWORD = get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD", None),
)
EMAIL_HOST = get_configuration_value(
instance_configuration,
"EMAIL_HOST",
os.environ.get("EMAIL_HOST", None),
)
EMAIL_FROM = get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", None),
)
EMAIL_USE_TLS = get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
EMAIL_PORT = get_configuration_value(
instance_configuration,
"EMAIL_PORT",
587,
)
EMAIL_FROM = get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
)
return (
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
)
+10 -20
View File
@@ -48,6 +48,7 @@ INSTALLED_APPS = [
"rest_framework.authtoken",
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"taggit",
"django_celery_beat",
"storages",
]
@@ -74,6 +75,10 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"DEFAULT_THROTTLE_CLASSES": ("plane.api.rate_limit.ApiKeyRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"api_key": "60/minute",
},
}
# Django Auth Backend
@@ -109,9 +114,7 @@ CSRF_COOKIE_SECURE = True
CORS_ALLOW_CREDENTIALS = True
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
# filter out empty strings
cors_allowed_origins = [
origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()
]
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
else:
@@ -186,9 +189,6 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Password reset time the number of seconds the uniquely generated uid will be valid
PASSWORD_RESET_TIMEOUT = 3600
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static")
@@ -240,7 +240,7 @@ if AWS_S3_ENDPOINT_URL:
# JWT Auth Configuration
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200),
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080),
"REFRESH_TOKEN_LIFETIME": timedelta(days=43200),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
@@ -287,12 +287,11 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.license.bgtasks.instance_verification_task",
)
# Sentry Settings
# Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"):
if bool(os.environ.get("SENTRY_DSN", False)):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[
@@ -311,6 +310,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").st
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
@@ -326,14 +326,4 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# License engine base url
LICENSE_ENGINE_BASE_URL = os.environ.get(
"LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so"
)
# instance key
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
+1 -1
View File
@@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries):
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter=date_term,
date_filter="created_at__date",
offset=date_query[2],
)
else:
+8 -5
View File
@@ -1,9 +1,14 @@
# base requirements
Django==4.2.7
django-braces==1.15.0
django-taggit==4.0.0
psycopg==3.1.12
django-oauth-toolkit==2.3.0
mistune==3.0.1
djangorestframework==3.14.0
redis==4.6.0
django-nested-admin==4.0.2
django-cors-headers==4.2.0
whitenoise==6.5.0
django-allauth==0.55.2
@@ -14,6 +19,8 @@ djangorestframework-simplejwt==5.3.0
sentry-sdk==1.30.0
django-storages==1.14
django-crum==0.7.9
django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.22.0
google-api-python-client==2.97.0
django-redis==5.3.0
@@ -29,8 +36,4 @@ scout-apm==2.26.1
openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
cryptography==41.0.5
lxml==4.9.3
boto3==1.28.40
posthog==3.0.2
+6
View File
@@ -1,3 +1,9 @@
-r base.txt
gunicorn==21.2.0
whitenoise==6.5.0
boto3==1.28.40
django-anymail==10.1
django-debug-toolbar==4.1.0
gevent==23.7.0
psycogreen==1.0.2
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<p>
Dear {{first_name}},<br /><br />
Welcome! Your account has been created.
Verify your email by clicking on the link below <br />
{{verification_url}}
successfully.<br /><br />
</p>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
## Coolify Setup
Access the `coolify-docker-compose` file [here](https://raw.githubusercontent.com/makeplane/plane/master/deploy/coolify/coolify-docker-compose.yml) or download using using below command
```
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/coolify/coolify-docker-compose.yml
```
-232
View File
@@ -1,232 +0,0 @@
services:
web:
container_name: web
platform: linux/amd64
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh web/server.js web
environment:
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=$SERVICE_FQDN_SPACE_8082
depends_on:
- api
- worker
space:
container_name: space
platform: linux/amd64
image: makeplane/plane-space:latest
restart: always
command: /usr/local/bin/start.sh space/server.js space
environment:
- SERVICE_FQDN_SPACE_8082=/api
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
depends_on:
- api
- worker
- web
api:
container_name: api
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/takeoff
environment:
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=redis://${REDIS_HOST}:6379/
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-8082}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- WEB_URL=$SERVICE_FQDN_PLANE_8082
depends_on:
- plane-db
- plane-redis
worker:
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
environment:
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=redis://${REDIS_HOST}:6379/
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-8082}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
environment:
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=redis://${REDIS_HOST}:6379/
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-8082}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
plane-db:
container_name: plane-db
image: postgres:15.2-alpine
restart: always
command: postgres -c 'max_connections=1000'
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
plane-redis:
container_name: plane-redis
image: redis:6.2.7-alpine
restart: always
volumes:
- redisdata:/data
plane-minio:
container_name: plane-minio
image: minio/minio
restart: always
command: server /export --console-address ":9090"
volumes:
- uploads:/export
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-access-key}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-secret-key}
createbuckets:
image: minio/mc
entrypoint: >
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
environment:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-minio
# Comment this if you already have a reverse proxy running
proxy:
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy:latest
ports:
- 8082:80
environment:
- SERVICE_FQDN_PLANE_8082
- NGINX_PORT=${NGINX_PORT:-8082}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
-8
View File
@@ -1,8 +0,0 @@
# Helm Chart
Click on the below link to access the helm chart instructions.
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/makeplane)](https://artifacthub.io/packages/search?repo=makeplane)
-309
View File
@@ -1,309 +0,0 @@
# Self Hosting
In this guide, we will walk you through the process of setting up a self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
We will cover two main options for setting up your self-hosted environment: using a cloud server or using your desktop. For the cloud server, we will use an AWS EC2 instance. For the desktop, we will use Docker to create a local environment.
Let's get started!
## Setting up Docker Environment
<details>
<summary>Option 1 - Using Cloud Server</summary>
<p>Best way to start is to create EC2 maching on AWS. It must of minimum t3.medium/t3a/medium</p>
<p>Run the below command to install docker engine.</p>
```curl -fsSL https://get.docker.com -o install-docker.sh```
</details>
---
<details>
<summary>Option 2 - Using Desktop</summary>
#### For Mac
<ol>
<li> Download Docker Desktop for Mac from the <a href="https://hub.docker.com/editions/community/docker-ce-desktop-mac/" target="_blank">Docker Hub</a>. </li>
<li> Double-click the downloaded `.dmg` file and drag the Docker app icon to the Applications folder. </li>
<li>Open Docker Desktop from the Applications folder. You might be asked to provide your system password to install additional software.</li>
</ol>
#### For Windows:
<ol>
<li>Download Docker Desktop for Windows from the <a href="https://hub.docker.com/editions/community/docker-ce-desktop-windows/" target="_blank">Docker Hub</a>.</li>
<li>Run the installer and follow the instructions. You might be asked to enable Hyper-V and "Containers" Windows features.</li>
<li>Open Docker Desktop. You might be asked to log out and log back in, or restart your machine, for changes to take effect.</li>
</ol>
After installation, you can verify the installation by opening a terminal (Command Prompt on Windows, Terminal app on Mac) and running the command `docker --version`. This should display the installed version of Docker.
</details>
---
## Installing Plane
Installing plane is a very easy and minimal step process.
### Prerequisite
- Docker installed and running
- OS with bash scripting enabled (Ubuntu, Linux AMI, macos). Windows systems need to have [gitbash](https://git-scm.com/download/win)
- User context used must have access to docker services. In most cases, use sudo su to switch as root user
- Use the terminal (or gitbash) window to run all the future steps
### Downloading Latest Stable Release
```
mkdir plane-selfhost
cd plane-selfhost
curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
chmod +x setup.sh
```
<details>
<summary>Downloading Preview Release</summary>
```
mkdir plane-selfhost
cd plane-selfhost
export RELEASE=preview
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/install.sh | sed 's@BRANCH=master@BRANCH='"$RELEASE"'@' > setup.sh
chmod +x setup.sh
```
</details>
---
### Proceed with setup
Above steps will set you ready to install and start plane services.
Lets get started by running the `./setup.sh` command.
This will prompt you with the below options.
```
Select a Action you want to perform:
1) Install
2) Start
3) Stop
4) Restart
5) Upgrade
6) Exit
Action [2]: 1
```
For the 1st time setup, type "1" as action input.
This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that
- `docker-compose.yaml`
- `.env`
Again the `options [1-6]` will be popped up and this time hit `6` to exit.
---
### Continue with setup - Environment Settings
Before proceeding, we suggest used to review `.env` file and set the values.
Below are the most import keys you must refer to. *<span style="color: #fcba03">You can use any text editor to edit this file</span>*.
> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.
---
### Continue with setup - Start Server
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices
```
Select a Action you want to perform:
1) Install
2) Start
3) Stop
4) Restart
5) Upgrade
6) Exit
Action [2]: 2
```
Expect something like this.
![Downloading docker images](images/download.png)
Be patient as it might take sometime based on download speed and system configuration. If all goes well, you must see something like this
![Downloading completed](images/started.png)
This is the confirmation that all images were downloaded and the services are up & running.
You have successfully self hosted `Plane` instance. Access the application by going to IP or domain you have configured it (e.g `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
---
### Stopping the Server
In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices
```
Select a Action you want to perform:
1) Install
2) Start
3) Stop
4) Restart
5) Upgrade
6) Exit
Action [2]: 3
```
If all goes well, you must see something like this
![Stop Services](images/stopped.png)
---
### Restarting the Server
In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices
```
Select a Action you want to perform:
1) Install
2) Start
3) Stop
4) Restart
5) Upgrade
6) Exit
Action [2]: 4
```
If all goes well, you must see something like this
![Restart Services](images/restart.png)
---
### Upgrading Plane Version
It is always advised to keep Plane up to date with the latest release.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
```
Select a Action you want to perform:
1) Install
2) Start
3) Stop
4) Restart
5) Upgrade
6) Exit
Action [2]: 5
```
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `variables-upgrade.env`. Here system will not replace `.env` with the new one.
You must expect the below message
![Alt text](images/upgrade.png)
Once done, choose `6` to exit from prompt.
> It is very important for you to compare the 2 files `variables-upgrade.env` and `.env`. Copy the newly added variable from downloaded file to `.env` and set the expected values.
Once done with making changes in `.env` file, jump on to `Start Server`
## Upgrading from v0.13.2 to v0.14.x
This is one time activity for users who are upgrading from v0.13.2 to v0.14.0
As there has been significant changes to Self Hosting process, this step mainly covers the data migration from current (v0.13.2) docker volumes from newly created volumes
> Before we begin with migration, make sure your v0.14.0 was started and then stopped. This is required to know the newly created docker volume names.
Begin with downloading the migration script using below command
```
curl -fsSL -o migrate.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/migration-0.13-0.14.sh
chmod +x migrate.sh
```
Now run the `./migrate.sh` command and expect the instructions as below
```
******************************************************************
This script is solely for the migration purpose only.
This is a 1 time migration of volume data from v0.13.2 => v0.14.x
Assumption:
1. Postgres data volume name ends with _pgdata
2. Minio data volume name ends with _uploads
3. Redis data volume name ends with _redisdata
Any changes to this script can break the migration.
Before you proceed, make sure you run the below command
to know the docker volumes
docker volume ls -q | grep -i "_pgdata"
docker volume ls -q | grep -i "_uploads"
docker volume ls -q | grep -i "_redisdata"
*******************************************************
Given below list of REDIS volumes, identify the prefix of source and destination volumes leaving "_redisdata"
---------------------
plane-app_redisdata
v0132_redisdata
Provide the Source Volume Prefix :
```
**Open another terminal window**, and run the mentioned 3 command. This may be different for users who have changed the volume names in their previous setup (v0.13.2)
For every command you must see 2 records something like shown in above example of `redisdata`
To move forward, you would need PREFIX of old setup and new setup. As per above example, `v0132` is the prefix of v0.13.2 and `plane-app` is the prefix of v0.14.0 setup
**Back to original terminal window**, *Provide the Source Volume Prefix* and hit ENTER.
Now you will be prompted to *Provide Destination Volume Prefix*. Provide the value and hit ENTER
```
Provide the Source Volume Prefix : v0132
Provide the Destination Volume Prefix : plane-app
```
In case the suffixes are wrong or the mentioned volumes are not found, you will receive the error shown below. The image below displays an error for source volumes.
![Migrate Error](images/migrate-error.png)
In case of successful migration, it will be a silent exit without error.
Now its time to restart v0.14.0 setup.
+21 -16
View File
@@ -5,16 +5,16 @@ x-app-env : &app-env
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} # deprecated
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} # deprecated
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- ADMIN_EMAIL=${ADMIN_EMAIL:-""}
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-""}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS
@@ -29,12 +29,12 @@ x-app-env : &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
# EMAIL SETTINGS - Deprecated can be configured through admin panel
# EMAIL SETTINGS
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane &lt;team@mailer.plane.so&gt;"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
@@ -43,11 +43,10 @@ x-app-env : &app-env
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
# LOGIN/SIGNUP SETTINGS
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
@@ -67,7 +66,7 @@ services:
web:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-frontend-ce:${APP_RELEASE:-latest}
image: makeplane/plane-frontend:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
@@ -79,7 +78,7 @@ services:
space:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-space-ce:${APP_RELEASE:-latest}
image: makeplane/plane-space:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
@@ -92,7 +91,7 @@ services:
api:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/takeoff
deploy:
@@ -103,8 +102,9 @@ services:
worker:
<<: *app-env
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/worker
depends_on:
@@ -114,8 +114,9 @@ services:
beat-worker:
<<: *app-env
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend-ce:${APP_RELEASE:-latest}
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/beat
depends_on:
@@ -125,6 +126,7 @@ services:
plane-db:
<<: *app-env
container_name: plane-db
image: postgres:15.2-alpine
restart: unless-stopped
command: postgres -c 'max_connections=1000'
@@ -133,6 +135,7 @@ services:
plane-redis:
<<: *app-env
container_name: plane-redis
image: redis:6.2.7-alpine
restart: unless-stopped
volumes:
@@ -140,6 +143,7 @@ services:
plane-minio:
<<: *app-env
container_name: plane-minio
image: minio/minio
restart: unless-stopped
command: server /export --console-address ":9090"
@@ -149,8 +153,9 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
<<: *app-env
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy-ce:${APP_RELEASE:-latest}
image: makeplane/plane-proxy:${APP_RELEASE:-latest}
ports:
- ${NGINX_PORT}:80
depends_on:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

+6 -1
View File
@@ -30,7 +30,12 @@ function download(){
if [ "$BRANCH" != "master" ];
then
cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml
sed -e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \
sed -e 's@plane-frontend:@plane-frontend-private:@g' \
-e 's@plane-space:@plane-space-private:@g' \
-e 's@plane-backend:@plane-backend-private:@g' \
-e 's@plane-proxy:@plane-proxy-private:@g' \
-e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \
$PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml
rm $PLANE_INSTALL_DIR/temp.yaml

Some files were not shown because too many files have changed in this diff Show More