diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 46ab3c4a4..5381995fd 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -159,10 +159,10 @@ class ChangePasswordSerializer(serializers.Serializer): def validate(self, data): if data.get("old_password") == data.get("new_password"): - raise serializers.ValidationError("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("confirm password should be same as the new password.") + raise serializers.ValidationError({"error": "Confirm password should be same as the new password."}) return data diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 739d17c55..c449c0fcc 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -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//user-issues//", + WorkspaceUserProfileIssuesGroupedEndpoint.as_view(), + name="workspace-user-profile-issues", + ), path( "workspaces//labels/", WorkspaceLabelsEndpoint.as_view(), diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index e36d6a14b..ab81f99c9 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -44,6 +44,7 @@ from .workspace import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceUserProfileIssuesGroupedEndpoint ) from .state import StateViewSet from .view import ( diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index da3130e64..37c8b3d85 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -133,7 +133,7 @@ class ChangePasswordEndpoint(BaseAPIView): if serializer.is_valid(): if not user.check_password(serializer.data.get("old_password")): return Response( - {"old_password": ["Wrong password."]}, + {"error": "Old password is not correct"}, status=status.HTTP_400_BAD_REQUEST, ) # set_password also hashes the password that the user will get diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 93d381117..61a79b984 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -1,4 +1,5 @@ # Python imports +import os import uuid import random import string @@ -32,6 +33,8 @@ from plane.db.models import ( ) from plane.settings.redis import redis_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 def get_tokens_for_user(user): @@ -46,7 +49,17 @@ class SignUpEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): - if not settings.ENABLE_SIGNUP: + 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": "New account creation is disabled. Please contact your site administrator" @@ -140,7 +153,8 @@ class SignUpEndpoint(BaseAPIView): else 15, member=user, created_by_id=project_member_invite.created_by_id, - ) for project_member_invite in project_member_invites + ) + for project_member_invite in project_member_invites ], ignore_conflicts=True, ) @@ -224,15 +238,9 @@ class SignInEndpoint(BaseAPIView): }, status=status.HTTP_403_FORBIDDEN, ) - if not user.is_active: - return Response( - { - "error": "Your account has been deactivated. Please contact your site administrator." - }, - status=status.HTTP_403_FORBIDDEN, - ) # 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") @@ -288,7 +296,8 @@ class SignInEndpoint(BaseAPIView): else 15, member=user, created_by_id=project_member_invite.created_by_id, - ) for project_member_invite in project_member_invites + ) + for project_member_invite in project_member_invites ], ignore_conflicts=True, ) @@ -360,6 +369,31 @@ class MagicSignInGenerateEndpoint(BaseAPIView): 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"}, @@ -410,8 +444,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView): ri.set(key, json.dumps(value), ex=expiry) - - current_site = request.META.get('HTTP_ORIGIN') + current_site = request.META.get("HTTP_ORIGIN") magic_link.delay(email, key, token, current_site) return Response({"key": key}, status=status.HTTP_200_OK) @@ -443,13 +476,6 @@ class MagicSignInEndpoint(BaseAPIView): if str(token) == str(user_token): 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, - ) try: # Send event to Jitsu for tracking if settings.ANALYTICS_BASE_API: @@ -467,7 +493,9 @@ class MagicSignInEndpoint(BaseAPIView): "user": {"email": email, "id": str(user.id)}, "device_ctx": { "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + "user_agent": request.META.get( + "HTTP_USER_AGENT" + ), }, "event_type": "SIGN_IN", }, @@ -498,7 +526,9 @@ class MagicSignInEndpoint(BaseAPIView): "user": {"email": email, "id": str(user.id)}, "device_ctx": { "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), + "user_agent": request.META.get( + "HTTP_USER_AGENT" + ), }, "event_type": "SIGN_UP", }, @@ -506,6 +536,7 @@ class MagicSignInEndpoint(BaseAPIView): except RequestException as e: capture_exception(e) + user.is_active = True user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") @@ -561,7 +592,8 @@ class MagicSignInEndpoint(BaseAPIView): else 15, member=user, created_by_id=project_member_invite.created_by_id, - ) for project_member_invite in project_member_invites + ) + for project_member_invite in project_member_invites ], ignore_conflicts=True, ) diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 3052b6077..a585fc82e 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -45,23 +45,23 @@ class ConfigurationEndpoint(BaseAPIView): get_configuration_value( instance_configuration, "EMAIL_HOST_USER", - os.environ.get("GITHUB_APP_NAME", None), + os.environ.get("EMAIL_HOST_USER", None), ), ) and bool( get_configuration_value( instance_configuration, "EMAIL_HOST_PASSWORD", - os.environ.get("GITHUB_APP_NAME", None), + os.environ.get("EMAIL_HOST_PASSWORD", None), ) ) ) and get_configuration_value( - instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" + instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1" ) == "1" data["email_password_login"] = ( get_configuration_value( - instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" + instance_configuration, "ENABLE_EMAIL_PASSWORD", "1" ) == "1" ) diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index 31b28415a..04810fddd 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -30,6 +30,8 @@ from plane.db.models import ( ProjectMember, ) from .base import BaseAPIView +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value def get_tokens_for_user(user): @@ -137,6 +139,40 @@ class OauthEndpoint(BaseAPIView): id_token = request.data.get("credential", False) client_id = request.data.get("clientId", False) + 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"), + ) + ) + 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 medium or not id_token: return Response( { @@ -174,15 +210,7 @@ class OauthEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - ## Login Case - if not user.is_active: - return Response( - { - "error": "Your account has been deactivated. Please contact your site administrator." - }, - status=status.HTTP_403_FORBIDDEN, - ) - + user.is_active = True user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") @@ -239,7 +267,8 @@ class OauthEndpoint(BaseAPIView): else 15, member=user, created_by_id=project_member_invite.created_by_id, - ) for project_member_invite in project_member_invites + ) + for project_member_invite in project_member_invites ], ignore_conflicts=True, ) @@ -291,6 +320,23 @@ class OauthEndpoint(BaseAPIView): except User.DoesNotExist: ## Signup Case + if ( + 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, + ) + username = uuid.uuid4().hex if "@" in email: @@ -373,7 +419,8 @@ class OauthEndpoint(BaseAPIView): else 15, member=user, created_by_id=project_member_invite.created_by_id, - ) for project_member_invite in project_member_invites + ) + for project_member_invite in project_member_invites ], ignore_conflicts=True, ) @@ -420,4 +467,4 @@ class OauthEndpoint(BaseAPIView): "access_token": access_token, "refresh_token": refresh_token, } - return Response(data, status=status.HTTP_201_CREATED) \ No newline at end of file + return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index 95ea5fedc..0e6bba944 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -12,11 +12,14 @@ from plane.app.serializers import ( ) from plane.app.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User, IssueActivity, WorkspaceMember +from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator +from django.db.models import Q, F, Count, Case, When, Value, IntegerField + + class UserEndpoint(BaseViewSet): serializer_class = UserSerializer model = User @@ -45,13 +48,68 @@ class UserEndpoint(BaseViewSet): def deactivate(self, request): # Check all workspace user is active user = self.get_object() - if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): - return Response( - { - "error": "You cannot deactivate account as you are a member in some workspaces." - }, - status=status.HTTP_400_BAD_REQUEST, - ) + + projects_to_deactivate = [] + workspaces_to_deactivate = [] + + + projects = ProjectMember.objects.filter( + member=request.user, is_active=True + ).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for project in projects: + if project.other_admin_exists > 0 or (project.total_members == 1): + project.is_active = False + projects_to_deactivate.append(project) + else: + return Response( + { + "error": "You cannot deactivate account as you are the only admin in some projects." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspaces = WorkspaceMember.objects.filter( + member=request.user, is_active=True + ).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for workspace in workspaces: + if workspace.other_admin_exists > 0 or (workspace.total_members == 1): + workspace.is_active = False + workspaces_to_deactivate.append(workspace) + else: + return Response( + { + "error": "You cannot deactivate account as you are the only admin in some workspaces." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectMember.objects.bulk_update( + projects_to_deactivate, ["is_active"], batch_size=100 + ) + + WorkspaceMember.objects.bulk_update( + workspaces_to_deactivate, ["is_active"], batch_size=100 + ) # Deactivate the user user.is_active = False diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 56f567bf4..3dc478609 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -1313,6 +1313,62 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): 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 = [ WorkspaceViewerPermission, diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 9e09bfa27..4ab44745d 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -45,7 +45,7 @@ class Command(BaseCommand): # 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(uuid.uuid4().hex) + user.set_password(admin_email) user.save() license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") @@ -88,13 +88,11 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS( - f"Instance succesfully registered with owner: {instance.primary_owner.email}" + f"Instance successfully registered with owner: {instance.primary_owner.email}" ) ) return - - self.stdout.write(self.style.WARNING("Instance could not be registered")) - return + raise CommandError("Instance could not be registered") else: self.stdout.write( self.style.SUCCESS(