diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index 3640904431..813cb068f4 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -24,55 +24,51 @@ class ProjectSerializer(BaseSerializer): fields = "__all__" read_only_fields = ["workspace", "deleted_at"] + def validate_name(self, name): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter(name=name, workspace_id=workspace_id) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_NAME_ALREADY_EXIST", + ) + + return name + + def validate_identifier(self, identifier): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter( + identifier=identifier, workspace_id=workspace_id + ) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_IDENTIFIER_ALREADY_EXIST", + ) + + return identifier + def create(self, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() - if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") + workspace_id = self.context["workspace_id"] - if ProjectIdentifier.objects.filter( - name=identifier, workspace_id=self.context["workspace_id"] - ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") - project = Project.objects.create( - **validated_data, workspace_id=self.context["workspace_id"] - ) - _ = ProjectIdentifier.objects.create( - name=project.identifier, - project=project, - workspace_id=self.context["workspace_id"], + project = Project.objects.create(**validated_data, workspace_id=workspace_id) + + ProjectIdentifier.objects.create( + name=project.identifier, project=project, workspace_id=workspace_id ) + return project - def update(self, instance, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() - - # If identifier is not passed update the project and return - if identifier == "": - project = super().update(instance, validated_data) - return project - - # If no Project Identifier is found create it - project_identifier = ProjectIdentifier.objects.filter( - name=identifier, workspace_id=instance.workspace_id - ).first() - if project_identifier is None: - project = super().update(instance, validated_data) - project_identifier = ProjectIdentifier.objects.filter( - project=project - ).first() - if project_identifier is not None: - project_identifier.name = identifier - project_identifier.save() - return project - # If found check if the project_id to be updated and identifier project id is same - if project_identifier.project_id == instance.id: - # If same pass update - project = super().update(instance, validated_data) - return project - - # If not same fail update - raise serializers.ValidationError(detail="Project Identifier is already taken") - class ProjectLiteSerializer(BaseSerializer): class Meta: diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 2728bf4de4..1da2aa84b2 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -239,205 +239,165 @@ class ProjectViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def create(self, request, slug): - try: - workspace = Workspace.objects.get(slug=slug) + workspace = Workspace.objects.get(slug=slug) - serializer = ProjectSerializer( - data={**request.data}, context={"workspace_id": workspace.id} + 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.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 + 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=request.user + IssueUserProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], ) - 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": "#60646C", - "sequence": 15000, - "group": "backlog", - "default": True, - }, - { - "name": "Todo", - "color": "#60646C", - "sequence": 25000, - "group": "unstarted", - }, - { - "name": "In Progress", - "color": "#F59E0B", - "sequence": 35000, - "group": "started", - }, - { - "name": "Done", - "color": "#46A758", - "sequence": 45000, - "group": "completed", - }, - { - "name": "Cancelled", - "color": "#9AA4BC", - "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=base_host(request=request, is_app=True), - ) - - 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", - "code": "PROJECT_NAME_ALREADY_EXIST", - }, - status=status.HTTP_409_CONFLICT, - ) - except Workspace.DoesNotExist: - return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - except serializers.ValidationError: - return Response( + # Default states + states = [ { - "identifier": "The project identifier is already taken", - "code": "PROJECT_IDENTIFIER_ALREADY_EXIST", + "name": "Backlog", + "color": "#60646C", + "sequence": 15000, + "group": "backlog", + "default": True, }, - status=status.HTTP_409_CONFLICT, + { + "name": "Todo", + "color": "#60646C", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#46A758", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#9AA4BC", + "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=base_host(request=request, is_app=True), + ) + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + 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=base_host(request=request, is_app=True), - ) - 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_409_CONFLICT, - ) - except (Project.DoesNotExist, Workspace.DoesNotExist): + # try: + if not ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=20, + is_active=True, + ).exists(): return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, ) - except serializers.ValidationError: + + 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( - {"identifier": "The project identifier is already taken"}, - status=status.HTTP_409_CONFLICT, + {"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=base_host(request=request, is_app=True), + ) + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( diff --git a/apps/web/ce/components/projects/create/root.tsx b/apps/web/ce/components/projects/create/root.tsx index c6b0552912..aeb8b3aaee 100644 --- a/apps/web/ce/components/projects/create/root.tsx +++ b/apps/web/ce/components/projects/create/root.tsx @@ -88,31 +88,63 @@ export const CreateProjectForm: FC = observer((props) = handleNextStep(res.id); }) .catch((err) => { - captureError({ - eventName: PROJECT_TRACKER_EVENTS.create, - payload: { - identifier: formData.identifier, - }, - }); - if (err?.data.code === "PROJECT_NAME_ALREADY_EXIST") { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("project_name_already_taken"), + try { + captureError({ + eventName: PROJECT_TRACKER_EVENTS.create, + payload: { + identifier: formData.identifier, + }, }); - } else if (err?.data.code === "PROJECT_IDENTIFIER_ALREADY_EXIST") { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("project_identifier_already_taken"), - }); - } else { - Object.keys(err?.data ?? {}).map((key) => { + + // Handle the new error format where codes are nested in arrays under field names + const errorData = err?.data ?? {}; + + // Check for specific error codes in the new format + if (errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST")) { setToast({ type: TOAST_TYPE.ERROR, - title: t("error"), - message: err.data[key], + title: t("toast.error"), + message: t("project_name_already_taken"), }); + } + + if (errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST")) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_identifier_already_taken"), + }); + } + + // Handle other field-specific errors (excluding name and identifier which are handled above) + Object.keys(errorData).forEach((field) => { + // Skip name and identifier fields as they're handled separately above + if (field === "name" || field === "identifier") return; + + const fieldErrors = errorData[field]; + if (Array.isArray(fieldErrors)) { + fieldErrors.forEach((errorMessage) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: errorMessage, + }); + }); + } else if (typeof fieldErrors === "string") { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: fieldErrors, + }); + } + }); + } catch (error) { + // Fallback error handling if the error processing fails + console.error("Error processing API error:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("something_went_wrong"), }); } }); diff --git a/apps/web/core/components/project/form.tsx b/apps/web/core/components/project/form.tsx index 26c96596f2..c93a316d84 100644 --- a/apps/web/core/components/project/form.tsx +++ b/apps/web/core/components/project/form.tsx @@ -104,18 +104,66 @@ export const ProjectDetailsForm: FC = (props) => { message: t("project_settings.general.toast.success"), }); }) - .catch((error) => { - captureError({ - eventName: PROJECT_TRACKER_EVENTS.update, - payload: { - id: projectId, - }, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: error?.error ?? t("project_settings.general.toast.error"), - }); + .catch((err) => { + try { + captureError({ + eventName: PROJECT_TRACKER_EVENTS.update, + payload: { + id: projectId, + }, + }); + + // Handle the new error format where codes are nested in arrays under field names + const errorData = err ?? {}; + + // Check for specific error codes in the new format + if (errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST")) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_name_already_taken"), + }); + } + + if (errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST")) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_identifier_already_taken"), + }); + } + + // Handle other field-specific errors (excluding name and identifier which are handled above) + Object.keys(errorData).forEach((field) => { + // Skip name and identifier fields as they're handled separately above + if (field === "name" || field === "identifier") return; + + const fieldErrors = errorData[field]; + if (Array.isArray(fieldErrors)) { + fieldErrors.forEach((errorMessage) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: errorMessage, + }); + }); + } else if (typeof fieldErrors === "string") { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: fieldErrors, + }); + } + }); + } catch (error) { + // Fallback error handling if the error processing fails + console.error("Error processing API error:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("something_went_wrong"), + }); + } }); };