# Python imports import boto3 from django.conf import settings from django.utils import timezone import json # Django imports from django.db import IntegrityError from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response from rest_framework import serializers, status from rest_framework.permissions import AllowAny # Module imports from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, DeployBoardSerializer, ) from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE from plane.db.models import ( UserFavorite, Intake, DeployBoard, IssueUserProperty, Project, ProjectIdentifier, ProjectMember, State, Workspace, WorkspaceMember, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.exception_logger import log_exception class ProjectViewSet(BaseViewSet): serializer_class = ProjectListSerializer model = Project webhook_event = "project" def get_queryset(self): sort_order = ProjectMember.objects.filter( member=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), is_active=True, ).values("sort_order") return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .select_related( "workspace", "workspace__owner", "default_assignee", "project_lead" ) .annotate( is_favorite=Exists( UserFavorite.objects.filter( user=self.request.user, entity_identifier=OuterRef("pk"), entity_type="project", project_id=OuterRef("pk"), ) ) ) .annotate( member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), member_id=self.request.user.id, is_active=True, ).values("role") ) .annotate( anchor=DeployBoard.objects.filter( entity_name="project", entity_identifier=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ).values("anchor") ) .annotate(sort_order=Subquery(sort_order)) .prefetch_related( Prefetch( "project_projectmember", queryset=ProjectMember.objects.filter( workspace__slug=self.kwargs.get("slug"), is_active=True ).select_related("member"), to_attr="members_list", ) ) .distinct() ) @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list_detail(self, request, slug): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( member=request.user, workspace__slug=slug, is_active=True, role=5 ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, project_projectmember__is_active=True, ) if WorkspaceMember.objects.filter( member=request.user, workspace__slug=slug, is_active=True, role=15 ).exists(): projects = projects.filter( Q( project_projectmember__member=self.request.user, project_projectmember__is_active=True, ) | Q(network=2) ) if request.GET.get("per_page", False) and request.GET.get("cursor", False): return self.paginate( order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(projects), on_results=lambda projects: ProjectListSerializer( projects, many=True ).data, ) projects = ProjectListSerializer( projects, many=True, fields=fields if fields else None ).data return Response(projects, status=status.HTTP_200_OK) @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): sort_order = ProjectMember.objects.filter( member=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), is_active=True, ).values("sort_order") projects = ( Project.objects.filter(workspace__slug=self.kwargs.get("slug")) .select_related( "workspace", "workspace__owner", "default_assignee", "project_lead" ) .annotate( member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), member_id=self.request.user.id, is_active=True, ).values("role") ) .annotate(inbox_view=F("intake_view")) .annotate(sort_order=Subquery(sort_order)) .distinct() ).values( "id", "name", "identifier", "sort_order", "logo_props", "member_role", "archived_at", "workspace", "cycle_view", "issue_views_view", "module_view", "page_view", "inbox_view", "guest_view_all_features", "project_lead", "network", "created_at", "updated_at", "created_by", "updated_by", ) if WorkspaceMember.objects.filter( member=request.user, workspace__slug=slug, is_active=True, role=5 ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, project_projectmember__is_active=True, ) if WorkspaceMember.objects.filter( member=request.user, workspace__slug=slug, is_active=True, role=15 ).exists(): projects = projects.filter( Q( project_projectmember__member=self.request.user, project_projectmember__is_active=True, ) | Q(network=2) ) return Response(projects, status=status.HTTP_200_OK) @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def retrieve(self, request, slug, pk): project = ( self.get_queryset() .filter( project_projectmember__member=self.request.user, project_projectmember__is_active=True, ) .filter(archived_at__isnull=True) .filter(pk=pk) ).first() if project is None: return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) recent_visited_task.delay( slug=slug, project_id=pk, entity_name="project", entity_identifier=pk, user_id=request.user.id, ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def create(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) serializer = ProjectSerializer( data={**request.data}, context={"workspace_id": workspace.id} ) if serializer.is_valid(): serializer.save() # Add the user as Administrator to the project _ = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20 ) # Also create the issue property for the user _ = IssueUserProperty.objects.create( project_id=serializer.data["id"], user=request.user ) if serializer.data["project_lead"] is not None and str( serializer.data["project_lead"] ) != str(request.user.id): ProjectMember.objects.create( project_id=serializer.data["id"], member_id=serializer.data["project_lead"], role=20, ) # Also create the issue property for the user IssueUserProperty.objects.create( project_id=serializer.data["id"], user_id=serializer.data["project_lead"], ) # Default states states = [ { "name": "Backlog", "color": "#A3A3A3", "sequence": 15000, "group": "backlog", "default": True, }, { "name": "Todo", "color": "#3A3A3A", "sequence": 25000, "group": "unstarted", }, { "name": "In Progress", "color": "#F59E0B", "sequence": 35000, "group": "started", }, { "name": "Done", "color": "#16A34A", "sequence": 45000, "group": "completed", }, { "name": "Cancelled", "color": "#EF4444", "sequence": 55000, "group": "cancelled", }, ] State.objects.bulk_create( [ State( name=state["name"], color=state["color"], project=serializer.instance, sequence=state["sequence"], workspace=serializer.instance.workspace, group=state["group"], default=state.get("default", False), created_by=request.user, ) for state in states ] ) project = self.get_queryset().filter(pk=serializer.data["id"]).first() # Create the model activity model_activity.delay( model_name="project", model_id=str(project.id), requested_data=request.data, current_instance=None, actor_id=request.user.id, slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) except Workspace.DoesNotExist: return Response( {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND ) except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) def partial_update(self, request, slug, pk=None): try: if not ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, role=20, is_active=True, ).exists(): return Response( {"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN, ) workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) intake_view = request.data.get("inbox_view", project.intake_view) current_instance = json.dumps( ProjectSerializer(project).data, cls=DjangoJSONEncoder ) if project.archived_at: return Response( {"error": "Archived projects cannot be updated"}, status=status.HTTP_400_BAD_REQUEST, ) serializer = ProjectSerializer( project, data={**request.data, "intake_view": intake_view}, context={"workspace_id": workspace.id}, partial=True, ) if serializer.is_valid(): serializer.save() if intake_view: intake = Intake.objects.filter( project=project, is_default=True ).first() if not intake: Intake.objects.create( name=f"{project.name} Intake", project=project, is_default=True, ) project = self.get_queryset().filter(pk=serializer.data["id"]).first() model_activity.delay( model_name="project", model_id=str(project.id), requested_data=request.data, current_instance=current_instance, actor_id=request.user.id, slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( member=request.user, workspace__slug=slug, is_active=True, role=20 ).exists() or ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, role=20, is_active=True, ).exists() ): project = Project.objects.get(pk=pk) project.delete() webhook_activity.delay( event="project", verb="deleted", field=None, old_value=None, new_value=None, actor_id=request.user.id, slug=slug, current_site=request.META.get("HTTP_ORIGIN"), event_id=project.id, old_identifier=None, new_identifier=None, ) # Delete the project members DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() # Delete the user favorite UserFavorite.objects.filter(project_id=pk, workspace__slug=slug).delete() return Response(status=status.HTTP_204_NO_CONTENT) else: return Response( {"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN, ) class ProjectArchiveUnarchiveEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() project.save() UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() return Response( {"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def delete(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = None project.save() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectIdentifierEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): name = request.GET.get("name", "").strip().upper() if name == "": return Response( {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) exists = ProjectIdentifier.objects.filter( name=name, workspace__slug=slug ).values("id", "name", "project") return Response( {"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def delete(self, request, slug): name = request.data.get("name", "").strip().upper() if name == "": return Response( {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): return Response( {"error": "Cannot delete an identifier of an existing project"}, status=status.HTTP_400_BAD_REQUEST, ) ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectUserViewsEndpoint(BaseAPIView): def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project_member = ProjectMember.objects.filter( member=request.user, project=project, is_active=True ).first() if project_member is None: return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) view_props = project_member.view_props default_props = project_member.default_props preferences = project_member.preferences sort_order = project_member.sort_order project_member.view_props = request.data.get("view_props", view_props) project_member.default_props = request.data.get("default_props", default_props) project_member.preferences = request.data.get("preferences", preferences) project_member.sort_order = request.data.get("sort_order", sort_order) project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectFavoritesViewSet(BaseViewSet): model = UserFavorite def get_queryset(self): return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(user=self.request.user) .select_related( "project", "project__project_lead", "project__default_assignee" ) .select_related("workspace", "workspace__owner") ) def perform_create(self, serializer): serializer.save(user=self.request.user) def create(self, request, slug): _ = UserFavorite.objects.create( user=request.user, entity_type="project", entity_identifier=request.data.get("project"), project_id=request.data.get("project"), ) return Response(status=status.HTTP_204_NO_CONTENT) def destroy(self, request, slug, project_id): project_favorite = UserFavorite.objects.get( entity_identifier=project_id, entity_type="project", project=project_id, user=request.user, workspace__slug=slug, ) project_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) class ProjectPublicCoverImagesEndpoint(BaseAPIView): permission_classes = [AllowAny] # Cache the below api for 24 hours @cache_response(60 * 60 * 24, user=False) def get(self, request): files = [] if settings.USE_MINIO: s3 = boto3.client( "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, ) else: s3 = boto3.client( "s3", aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, ) params = { "Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Prefix": "static/project-cover/", } try: response = s3.list_objects_v2(**params) # Extracting file keys from the response if "Contents" in response: for content in response["Contents"]: if not content["Key"].endswith( "/" ): # This line ensures we're only getting files, not "sub-folders" files.append( f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" ) return Response(files, status=status.HTTP_200_OK) except Exception as e: log_exception(e) return Response([], status=status.HTTP_200_OK) class DeployBoardViewSet(BaseViewSet): permission_classes = [ProjectMemberPermission] serializer_class = DeployBoardSerializer model = DeployBoard def list(self, request, slug, project_id): project_deploy_board = DeployBoard.objects.filter( entity_name="project", entity_identifier=project_id, workspace__slug=slug ).first() serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): comments = request.data.get("is_comments_enabled", False) reactions = request.data.get("is_reactions_enabled", False) intake = request.data.get("intake", None) votes = request.data.get("is_votes_enabled", False) views = request.data.get( "views", { "list": True, "kanban": True, "calendar": True, "gantt": True, "spreadsheet": True, }, ) project_deploy_board, _ = DeployBoard.objects.get_or_create( entity_name="project", entity_identifier=project_id, project_id=project_id ) project_deploy_board.intake = intake project_deploy_board.view_props = views project_deploy_board.is_votes_enabled = votes project_deploy_board.is_comments_enabled = comments project_deploy_board.is_reactions_enabled = reactions project_deploy_board.save() serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK)