This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,5 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .instance import InstanceAdminPermission
@@ -0,0 +1,18 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework.permissions import BasePermission
# Module imports
from plane.license.models import Instance, InstanceAdmin
class InstanceAdminPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
instance = Instance.objects.first()
return InstanceAdmin.objects.filter(role__gte=15, instance=instance, user=request.user).exists()
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
@@ -0,0 +1,42 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from .base import BaseSerializer
from plane.db.models import User
from plane.app.serializers import UserAdminLiteSerializer
from plane.license.models import InstanceAdmin
class InstanceAdminMeSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"avatar",
"avatar_url",
"cover_image",
"date_joined",
"display_name",
"email",
"first_name",
"last_name",
"is_active",
"is_bot",
"is_email_verified",
"user_timezone",
"username",
"is_password_autoset",
"is_email_verified",
]
read_only_fields = fields
class InstanceAdminSerializer(BaseSerializer):
user_detail = UserAdminLiteSerializer(source="user", read_only=True)
class Meta:
model = InstanceAdmin
fields = "__all__"
read_only_fields = ["id", "instance", "user"]
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -0,0 +1,21 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .base import BaseSerializer
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import decrypt_data
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
@@ -0,0 +1,17 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Module imports
from plane.license.models import Instance
from plane.app.serializers import BaseSerializer
from plane.app.serializers import UserAdminLiteSerializer
class InstanceSerializer(BaseSerializer):
primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True)
class Meta:
model = Instance
fields = "__all__"
read_only_fields = ["id", "email", "last_checked_at", "is_setup_done"]
@@ -0,0 +1,12 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name"]
@@ -0,0 +1,41 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third Party Imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Workspace
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
class WorkspaceSerializer(BaseSerializer):
owner = UserLiteSerializer(read_only=True)
logo_url = serializers.CharField(read_only=True)
total_projects = serializers.IntegerField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Check uniqueness case-insensitively
if Workspace.objects.filter(slug__iexact=value).exists():
raise serializers.ValidationError("Slug is already in use")
return value
class Meta:
model = Workspace
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"owner",
"logo_url",
]
@@ -0,0 +1,28 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint
from .configuration import (
EmailCredentialCheckEndpoint,
InstanceConfigurationEndpoint,
DisableEmailFeatureEndpoint,
)
from .admin import (
InstanceAdminEndpoint,
InstanceAdminSignInEndpoint,
InstanceAdminSignUpEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
)
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
@@ -0,0 +1,397 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
from urllib.parse import urlencode, urljoin
import uuid
# Django imports
from django.http import HttpResponseRedirect
from django.views import View
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.contrib.auth import logout
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.license.api.serializers import (
InstanceAdminMeSerializer,
InstanceAdminSerializer,
)
from plane.license.models import Instance, InstanceAdmin
from plane.db.models import User, Profile
from plane.utils.cache import cache_response, invalidate_cache
from plane.authentication.utils.login import user_login
from plane.authentication.utils.host import base_host, user_ip
from plane.authentication.utils.password_validation import is_password_policy_valid
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.ip_address import get_client_ip
from plane.utils.path_validator import get_safe_redirect_url
class InstanceAdminEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
@invalidate_cache(path="/api/instances/", user=False)
# Create an instance admin
def post(self, request):
email = request.data.get("email", False)
role = request.data.get("role", 20)
if not email:
return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the user
user = User.objects.get(email=email)
instance_admin = InstanceAdmin.objects.create(instance=instance, user=user, role=role)
serializer = InstanceAdminSerializer(instance_admin)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not registered yet"},
status=status.HTTP_403_FORBIDDEN,
)
instance_admins = InstanceAdmin.objects.filter(instance=instance)
serializer = InstanceAdminSerializer(instance_admins, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def delete(self, request, pk):
instance = Instance.objects.first()
InstanceAdmin.objects.filter(instance=instance, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class InstanceAdminSignUpEndpoint(View):
permission_classes = [AllowAny]
@invalidate_cache(path="/api/instances/", user=False)
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# check if the instance has already an admin registered
if InstanceAdmin.objects.first():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_ALREADY_EXIST"],
error_message="ADMIN_ALREADY_EXIST",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Get the email and password from all the user
email = request.POST.get("email", False)
password = request.POST.get("password", False)
first_name = request.POST.get("first_name", False)
last_name = request.POST.get("last_name", "")
company_name = request.POST.get("company_name", "")
is_telemetry_enabled = request.POST.get("is_telemetry_enabled", True)
# return error if the email and password is not present
if not email or not password or not first_name:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"],
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(
request=request,
is_admin=True,
),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
error_message="INVALID_ADMIN_EMAIL",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if already a user exists or not
# Existing user
if User.objects.filter(email=email).exists():
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_ALREADY_EXIST"],
error_message="ADMIN_USER_ALREADY_EXIST",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
else:
if not is_password_policy_valid(password):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
payload={
"email": email,
"first_name": first_name,
"last_name": last_name,
"company_name": company_name,
"is_telemetry_enabled": is_telemetry_enabled,
},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
user = User.objects.create(
first_name=first_name,
last_name=last_name,
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
_ = Profile.objects.create(user=user, company_name=company_name)
# 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 = get_client_ip(request=request)
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.instance_name = company_name
instance.is_telemetry_enabled = is_telemetry_enabled
instance.save()
# get tokens for user
user_login(request=request, user=user, is_admin=True)
url = urljoin(base_host(request=request, is_admin=True), "general/")
return HttpResponseRedirect(url)
class InstanceAdminSignInEndpoint(View):
permission_classes = [AllowAny]
@invalidate_cache(path="/api/instances/", user=False)
def post(self, request):
# Check instance first
instance = Instance.objects.first()
if instance is None:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Get email and password
email = request.POST.get("email", False)
password = request.POST.get("password", False)
# return error if the email and password is not present
if not email or not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_ADMIN_EMAIL_PASSWORD"],
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"],
error_message="INVALID_ADMIN_EMAIL",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Fetch the user
user = User.objects.filter(email=email).first()
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# is_active
if not user.is_active:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check password of the user
if not user.check_password(password):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"],
error_message="ADMIN_AUTHENTICATION_FAILED",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check if the user is an instance admin
if not InstanceAdmin.objects.filter(instance=instance, user=user):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"],
error_message="ADMIN_AUTHENTICATION_FAILED",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# 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 = get_client_ip(request=request)
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# get tokens for user
user_login(request=request, user=user, is_admin=True)
url = urljoin(base_host(request=request, is_admin=True), "general/")
return HttpResponseRedirect(url)
class InstanceAdminUserMeEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def get(self, request):
serializer = InstanceAdminMeSerializer(request.user)
return Response(serializer.data, status=status.HTTP_200_OK)
class InstanceAdminUserSessionEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request):
if request.user.is_authenticated and InstanceAdmin.objects.filter(user=request.user).exists():
serializer = InstanceAdminMeSerializer(request.user)
data = {"is_authenticated": True}
data["user"] = serializer.data
return Response(data, status=status.HTTP_200_OK)
else:
return Response({"is_authenticated": False}, status=status.HTTP_200_OK)
class InstanceAdminSignOutEndpoint(View):
permission_classes = [InstanceAdminPermission]
def post(self, request):
# Get user
try:
user = User.objects.get(pk=request.user.id)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
# Log the user out
logout(request)
url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="")
return HttpResponseRedirect(url)
except Exception:
url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="")
return HttpResponseRedirect(url)
@@ -0,0 +1,119 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework.views import APIView
# Module imports
from plane.license.api.permissions import InstanceAdminPermission
from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [InstanceAdminPermission]
filter_backends = (DjangoFilterBackend, SearchFilter)
authentication_classes = [BaseSessionAuthentication]
filterset_fields = []
search_fields = []
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
@property
def fields(self):
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None
@property
def expand(self):
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
@@ -0,0 +1,170 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
from smtplib import (
SMTPAuthenticationError,
SMTPConnectError,
SMTPRecipientsRefused,
SMTPSenderRefused,
SMTPServerDisconnected,
)
# Django imports
from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection
from django.db.models import Q, Case, When, Value
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.license.models import InstanceConfiguration
from plane.license.api.serializers import InstanceConfigurationSerializer
from plane.license.utils.encryption import encrypt_data
from plane.utils.cache import cache_response, invalidate_cache
from plane.license.utils.instance_value import get_email_configuration
class InstanceConfigurationEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
instance_configurations = InstanceConfiguration.objects.all()
serializer = InstanceConfigurationSerializer(instance_configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/configurations/", user=False)
@invalidate_cache(path="/api/instances/", user=False)
def patch(self, request):
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
bulk_configurations.append(configuration)
InstanceConfiguration.objects.bulk_update(bulk_configurations, ["value"], batch_size=100)
serializer = InstanceConfigurationSerializer(configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class DisableEmailFeatureEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
@invalidate_cache(path="/api/instances/", user=False)
def delete(self, request):
try:
InstanceConfiguration.objects.filter(
Q(
key__in=[
"EMAIL_HOST",
"EMAIL_HOST_USER",
"EMAIL_HOST_PASSWORD",
"ENABLE_SMTP",
"EMAIL_PORT",
"EMAIL_FROM",
]
)
).update(value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value("")))
return Response(status=status.HTTP_200_OK)
except Exception:
return Response(
{"error": "Failed to disable email configuration"},
status=status.HTTP_400_BAD_REQUEST,
)
class EmailCredentialCheckEndpoint(BaseAPIView):
def post(self, request):
receiver_email = request.data.get("receiver_email", False)
if not receiver_email:
return Response(
{"error": "Receiver email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_USE_SSL,
EMAIL_FROM,
) = get_email_configuration()
# Configure all the connections
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
use_ssl=EMAIL_USE_SSL == "1",
)
# Prepare email details
subject = "Email Notification from Plane"
message = "This is a sample email notification sent from Plane application."
# Send the email
try:
msg = EmailMultiAlternatives(
subject=subject,
body=message,
from_email=EMAIL_FROM,
to=[receiver_email],
connection=connection,
)
msg.send(fail_silently=False)
return Response({"message": "Email successfully sent."}, status=status.HTTP_200_OK)
except BadHeaderError:
return Response({"error": "Invalid email header."}, status=status.HTTP_400_BAD_REQUEST)
except SMTPAuthenticationError:
return Response(
{"error": "Invalid credentials provided"},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPConnectError:
return Response(
{"error": "Could not connect with the SMTP server."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPSenderRefused:
return Response(
{"error": "From address is invalid."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPServerDisconnected:
return Response(
{"error": "SMTP server disconnected unexpectedly."},
status=status.HTTP_400_BAD_REQUEST,
)
except SMTPRecipientsRefused:
return Response(
{"error": "All recipient addresses were refused."},
status=status.HTTP_400_BAD_REQUEST,
)
except TimeoutError:
return Response(
{"error": "Timeout error while trying to connect to the SMTP server."},
status=status.HTTP_400_BAD_REQUEST,
)
except ConnectionError:
return Response(
{"error": "Network connection error. Please check your internet connection."},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception:
return Response(
{"error": "Could not send email. Please check your configuration"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -0,0 +1,214 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import Workspace
from plane.license.api.permissions import InstanceAdminPermission
from plane.license.api.serializers import InstanceSerializer
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
class InstanceEndpoint(BaseAPIView):
def get_permissions(self):
if self.request.method == "PATCH":
return [InstanceAdminPermission()]
return [AllowAny()]
@cache_response(60 * 60 * 2, user=False)
@method_decorator(cache_control(private=True, max_age=12))
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 instance
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
# Get all the configuration
(
ENABLE_SIGNUP,
DISABLE_WORKSPACE_CREATION,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
IS_GITEA_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
LLM_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
},
{
"key": "IS_GITHUB_ENABLED",
"default": os.environ.get("IS_GITHUB_ENABLED", "0"),
},
{
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", ""),
},
{
"key": "IS_GITLAB_ENABLED",
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
},
{
"key": "IS_GITEA_ENABLED",
"default": os.environ.get("IS_GITEA_ENABLED", "0"),
},
{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", None),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
},
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
# Github app name
data["github_app_name"] = str(GITHUB_APP_NAME)
# Slack client
data["slack_client_id"] = SLACK_CLIENT_ID
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_llm_configured"] = bool(LLM_API_KEY)
# File size settings
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL
data["app_base_url"] = settings.APP_BASE_URL
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
data["is_self_managed"] = settings.IS_SELF_MANAGED
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
response_data = {"config": data, "instance": instance_data}
return Response(response_data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/instances/", user=False)
def patch(self, request):
# Get the instance
instance = Instance.objects.first()
serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SignUpScreenVisitedEndpoint(BaseAPIView):
permission_classes = [AllowAny]
@invalidate_cache(path="/api/instances/", user=False)
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)
@@ -0,0 +1,110 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F
# Module imports
from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import Workspace, WorkspaceMember, Project
from plane.license.api.serializers import WorkspaceSerializer
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def get(self, request):
slug = request.GET.get("slug", False)
if not slug or slug == "":
return Response(
{"error": "Workspace Slug is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(slug__iexact=slug).exists() or slug in RESTRICTED_WORKSPACE_SLUGS
return Response({"status": not workspace}, status=status.HTTP_200_OK)
class InstanceWorkSpaceEndpoint(BaseAPIView):
model = Workspace
serializer_class = WorkspaceSerializer
permission_classes = [InstanceAdminPermission]
def get(self, request):
project_count = (
Project.objects.filter(workspace_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True)
.select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(total_projects=project_count, total_members=member_count)
# Add search functionality
search = request.query_params.get("search", None)
if search:
workspaces = workspaces.filter(name__icontains=search)
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
if not name or not slug:
return Response(
{"error": "Both name and slug are required"},
status=status.HTTP_400_BAD_REQUEST,
)
if len(name) > 80 or len(slug) > 48:
return Response(
{"error": "The maximum length for name is 80 and for slug is 48"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
_ = WorkspaceMember.objects.create(
workspace_id=serializer.data["id"],
member=request.user,
role=20,
company_role=request.data.get("company_role", ""),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_409_CONFLICT,
)
+9
View File
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.apps import AppConfig
class LicenseConfig(AppConfig):
name = "plane.license"
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,103 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from celery import shared_task
from opentelemetry import trace
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
Workspace,
Project,
Issue,
Module,
Cycle,
CycleIssue,
ModuleIssue,
Page,
WorkspaceMember,
)
from plane.utils.telemetry import init_tracer, shutdown_tracer
@shared_task
def instance_traces():
instance = Instance.objects.first()
# If telemetry is disabled or the instance is missing, don't initialize OTLP at all.
if instance is None or not instance.is_telemetry_enabled:
return
try:
init_tracer()
# Get the tracer
tracer = trace.get_tracer(__name__)
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute("is_telemetry_enabled", instance.is_telemetry_enabled)
span.set_attribute("is_support_required", instance.is_support_required)
span.set_attribute("is_setup_done", instance.is_setup_done)
span.set_attribute("is_signup_screen_visited", instance.is_signup_screen_visited)
span.set_attribute("is_verified", instance.is_verified)
span.set_attribute("edition", instance.edition)
span.set_attribute("domain", instance.domain)
span.set_attribute("is_test", instance.is_test)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(workspace=workspace).count()
module_issue_count = ModuleIssue.objects.filter(workspace=workspace).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(workspace=workspace).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
finally:
# Shutdown the tracer
shutdown_tracer()
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,152 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
# Django imports
from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import InstanceConfiguration
from plane.utils.instance_config_variables import instance_config_variables
class Command(BaseCommand):
help = "Configure instance variables"
def handle(self, *args, **options):
from plane.license.utils.encryption import encrypt_data
from plane.license.utils.instance_value import get_configuration_value
mandatory_keys = ["SECRET_KEY"]
for item in mandatory_keys:
if not os.environ.get(item):
raise CommandError(f"{item} env variable is required.")
for item in instance_config_variables:
obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("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.save()
self.stdout.write(self.style.SUCCESS(f"{obj.key} loaded with value from environment variable."))
else:
self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists"))
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", ""),
},
{
"key": "GOOGLE_CLIENT_SECRET",
"default": os.environ.get("GOOGLE_CLIENT_SECRET", "0"),
},
]
)
if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key=key,
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
if key == "IS_GITHUB_ENABLED":
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID", ""),
},
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET", "0"),
},
]
)
if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITHUB_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
if key == "IS_GITLAB_ENABLED":
GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITLAB_HOST",
"default": os.environ.get("GITLAB_HOST", "https://gitlab.com"),
},
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get("GITLAB_CLIENT_ID", ""),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get("GITLAB_CLIENT_SECRET", ""),
},
]
)
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITLAB_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
if key == "IS_GITEA_ENABLED":
GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITEA_HOST",
"default": os.environ.get("GITEA_HOST", ""),
},
{
"key": "GITEA_CLIENT_ID",
"default": os.environ.get("GITEA_CLIENT_ID", ""),
},
{
"key": "GITEA_CLIENT_SECRET",
"default": os.environ.get("GITEA_CLIENT_SECRET", ""),
},
]
)
if bool(GITEA_HOST) and bool(GITEA_CLIENT_ID) and bool(GITEA_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITEA_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
else:
for key in keys:
self.stdout.write(self.style.WARNING(f"{key} configuration already exists"))
@@ -0,0 +1,92 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import json
import secrets
import os
import requests
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
# Module imports
from plane.license.models import Instance, InstanceEdition
from plane.license.bgtasks.tracer import instance_traces
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 check_for_current_version(self):
if os.environ.get("APP_VERSION", False):
return os.environ.get("APP_VERSION")
try:
with open("package.json", "r") as file:
data = json.load(file)
return data.get("version", "v0.1.0")
except Exception:
self.stdout.write("Error checking for current version")
return "v0.1.0"
def check_for_latest_version(self, fallback_version):
try:
response = requests.get(
"https://api.github.com/repos/makeplane/plane/releases/latest",
timeout=10,
)
response.raise_for_status()
data = response.json()
return data.get("tag_name", fallback_version)
except Exception:
self.stdout.write("Error checking for latest version")
return fallback_version
def handle(self, *args, **options):
# Check if the instance is registered
instance = Instance.objects.first()
current_version = self.check_for_current_version()
latest_version = self.check_for_latest_version(current_version)
# If instance is None then register this instance
if instance is None:
machine_signature = options.get("machine_signature", "machine-signature")
if not machine_signature:
raise CommandError("Machine signature is required")
instance = Instance.objects.create(
instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
current_version=current_version,
latest_version=latest_version,
last_checked_at=timezone.now(),
is_test=os.environ.get("IS_TEST", "0") == "1",
edition=InstanceEdition.PLANE_COMMUNITY.value,
)
self.stdout.write(self.style.SUCCESS("Instance registered"))
else:
self.stdout.write(self.style.SUCCESS("Instance already registered"))
# Update the instance details
instance.last_checked_at = timezone.now()
instance.current_version = current_version
instance.latest_version = latest_version
instance.is_test = os.environ.get("IS_TEST", "0") == "1"
instance.edition = InstanceEdition.PLANE_COMMUNITY.value
instance.save()
# Call the instance traces task
instance_traces.delay()
return
@@ -0,0 +1,234 @@
# Generated by Django 4.2.7 on 2023-12-06 06:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Instance",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("instance_name", models.CharField(max_length=255)),
("whitelist_emails", models.TextField(blank=True, null=True)),
("instance_id", models.CharField(max_length=25, unique=True)),
(
"license_key",
models.CharField(blank=True, max_length=256, null=True),
),
("api_key", models.CharField(max_length=16)),
("version", models.CharField(max_length=10)),
("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",
),
),
(
"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={
"verbose_name": "Instance",
"verbose_name_plural": "Instances",
"db_table": "instances",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="InstanceConfiguration",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("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",
),
),
],
options={
"verbose_name": "Instance Configuration",
"verbose_name_plural": "Instance Configurations",
"db_table": "instance_configurations",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="InstanceAdmin",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"role",
models.PositiveIntegerField(
choices=[(20, "Admin")], default=20
),
),
("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",
),
),
(
"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",
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="instance_owner",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Instance Admin",
"verbose_name_plural": "Instance Admins",
"db_table": "instance_admins",
"ordering": ("-created_at",),
"unique_together": {("instance", "user")},
},
),
]
@@ -0,0 +1,106 @@
# Generated by Django 4.2.11 on 2024-05-31 10:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("license", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="instance",
name="instance_id",
field=models.CharField(max_length=255, unique=True),
),
migrations.RenameField(
model_name="instance",
old_name="version",
new_name="current_version",
),
migrations.RemoveField(
model_name="instance",
name="api_key",
),
migrations.AddField(
model_name="instance",
name="domain",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AddField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=50),
),
migrations.CreateModel(
name="ChangeLog",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("title", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("version", models.CharField(max_length=100)),
("tags", models.JSONField(default=list)),
("release_date", models.DateTimeField(null=True)),
("is_release_candidate", 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",
),
),
],
options={
"verbose_name": "Change Log",
"verbose_name_plural": "Change Logs",
"db_table": "changelogs",
"ordering": ("-created_at",),
},
),
]
@@ -0,0 +1,43 @@
# Generated by Django 4.2.11 on 2024-06-05 13:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0002_rename_version_instance_current_version_and_more"),
]
operations = [
migrations.AlterField(
model_name="changelog",
name="title",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="changelog",
name="version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="current_version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="namespace",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=255),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.2.11 on 2024-07-26 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('license', '0003_alter_changelog_title_alter_changelog_version_and_more'),
]
operations = [
migrations.AddField(
model_name='changelog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instance',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instanceadmin',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instanceconfiguration',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 4.2.15 on 2024-11-19 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0004_changelog_deleted_at_instance_deleted_at_and_more"),
]
operations = [
migrations.RenameField(
model_name="instance",
old_name="product",
new_name="edition",
),
migrations.RemoveField(
model_name="instance",
name="license_key",
),
migrations.RemoveField(
model_name="instance",
name="user_count",
),
migrations.AddField(
model_name="instance",
name="is_test",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="instance",
name="edition",
field=models.CharField(default="PLANE_COMMUNITY", max_length=255),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.22 on 2025-09-11 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0005_rename_product_instance_edition_and_more"),
]
operations = [
migrations.AddField(
model_name="instance",
name="is_current_version_deprecated",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,5 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .instance import Instance, InstanceAdmin, InstanceConfiguration, InstanceEdition
@@ -0,0 +1,100 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
from enum import Enum
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from plane.db.models import BaseModel
ROLE_CHOICES = ((20, "Admin"),)
class InstanceEdition(Enum):
PLANE_COMMUNITY = "PLANE_COMMUNITY"
class Instance(BaseModel):
# General information
instance_name = models.CharField(max_length=255)
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=255, unique=True)
current_version = models.CharField(max_length=255)
latest_version = models.CharField(max_length=255, null=True, blank=True)
edition = models.CharField(max_length=255, default=InstanceEdition.PLANE_COMMUNITY.value)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=255, 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)
is_verified = models.BooleanField(default=False)
is_test = models.BooleanField(default=False)
# field for validating if the current version is deprecated
is_current_version_deprecated = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
verbose_name_plural = "Instances"
db_table = "instances"
ordering = ("-created_at",)
class InstanceAdmin(BaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
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)
class Meta:
unique_together = ["instance", "user"]
verbose_name = "Instance Admin"
verbose_name_plural = "Instance Admins"
db_table = "instance_admins"
ordering = ("-created_at",)
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"
verbose_name_plural = "Instance Configurations"
db_table = "instance_configurations"
ordering = ("-created_at",)
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
version = models.CharField(max_length=255)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
class Meta:
verbose_name = "Change Log"
verbose_name_plural = "Change Logs"
db_table = "changelogs"
ordering = ("-created_at",)
+74
View File
@@ -0,0 +1,74 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.license.api.views import (
EmailCredentialCheckEndpoint,
InstanceAdminEndpoint,
InstanceAdminSignInEndpoint,
InstanceAdminSignUpEndpoint,
InstanceConfigurationEndpoint,
DisableEmailFeatureEndpoint,
InstanceEndpoint,
SignUpScreenVisitedEndpoint,
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
"admins/session/",
InstanceAdminUserSessionEndpoint.as_view(),
name="instance-admin-session",
),
path(
"admins/sign-out/",
InstanceAdminSignOutEndpoint.as_view(),
name="instance-admins",
),
path("admins/<uuid:pk>/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path(
"configurations/",
InstanceConfigurationEndpoint.as_view(),
name="instance-configuration",
),
path(
"configurations/disable-email-feature/",
DisableEmailFeatureEndpoint.as_view(),
name="disable-email-configuration",
),
path(
"admins/sign-in/",
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"admins/sign-up/",
InstanceAdminSignUpEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"admins/sign-up-screen-visited/",
SignUpScreenVisitedEndpoint.as_view(),
name="instance-sign-up",
),
path(
"email-credentials-check/",
EmailCredentialCheckEndpoint.as_view(),
name="email-credential-check",
),
path(
"workspace-slug-check/",
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,44 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import base64
import hashlib
from django.conf import settings
from cryptography.fernet import Fernet
from plane.utils.exception_logger import log_exception
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)
# Encrypt data
def encrypt_data(data):
try:
if data:
cipher_suite = Fernet(derive_key(settings.SECRET_KEY))
encrypted_data = cipher_suite.encrypt(data.encode())
return encrypted_data.decode() # Convert bytes to string
else:
return ""
except Exception as e:
log_exception(e)
return ""
# Decrypt data
def decrypt_data(encrypted_data):
try:
if 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()
else:
return ""
except Exception as e:
log_exception(e)
return ""
@@ -0,0 +1,59 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
# Django imports
from django.conf import settings
# Module imports
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import decrypt_data
# Helper function to return value from the passed key
def get_configuration_value(keys):
environment_list = []
if settings.SKIP_ENV_VAR:
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value", "is_encrypted")
for key in keys:
for item in instance_configuration:
if key.get("key") == item.get("key"):
if item.get("is_encrypted", False):
environment_list.append(decrypt_data(item.get("value")))
else:
environment_list.append(item.get("value"))
break
else:
environment_list.append(key.get("default"))
else:
# Get the configuration from os
for key in keys:
environment_list.append(os.environ.get(key.get("key"), key.get("default")))
return tuple(environment_list)
def get_email_configuration():
return get_configuration_value(
[
{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")},
{"key": "EMAIL_HOST_USER", "default": os.environ.get("EMAIL_HOST_USER")},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD"),
},
{"key": "EMAIL_PORT", "default": os.environ.get("EMAIL_PORT", 587)},
{"key": "EMAIL_USE_TLS", "default": os.environ.get("EMAIL_USE_TLS", "1")},
{"key": "EMAIL_USE_SSL", "default": os.environ.get("EMAIL_USE_SSL", "0")},
{
"key": "EMAIL_FROM",
"default": os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
},
]
)