diff --git a/apps/admin/package.json b/apps/admin/package.json index 0261d3485d..e81a3e073d 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "description": "Admin UI for Plane", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0", "private": true, "type": "module", diff --git a/apps/api/package.json b/apps/api/package.json index ffecb3a73e..6e62f2bd11 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0", "private": true, "description": "API server powering Plane's backend" diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index e9304c7ee0..1f57851142 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -981,7 +981,7 @@ class DuplicateAssetEndpoint(BaseAPIView): return {"entity_identifier": entity_id} return {} - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug, asset_id): project_id = request.data.get("project_id", None) entity_id = request.data.get("entity_id", None) @@ -1000,7 +1000,7 @@ class DuplicateAssetEndpoint(BaseAPIView): return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) - original_asset = FileAsset.objects.filter(workspace=workspace, id=asset_id, is_uploaded=True).first() + original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first() if not original_asset: return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/bgtasks/logger_task.py b/apps/api/plane/bgtasks/logger_task.py new file mode 100644 index 0000000000..01723ef77a --- /dev/null +++ b/apps/api/plane/bgtasks/logger_task.py @@ -0,0 +1,96 @@ +# Python imports +import logging +from typing import Optional, Dict, Any + +# Third party imports +from pymongo.collection import Collection +from celery import shared_task + +# Django imports +from plane.settings.mongo import MongoConnection +from plane.utils.exception_logger import log_exception +from plane.db.models import APIActivityLog + + +logger = logging.getLogger("plane.worker") + + +def get_mongo_collection() -> Optional[Collection]: + """ + Returns the MongoDB collection for external API activity logs. + """ + if not MongoConnection.is_configured(): + logger.info("MongoDB not configured") + return None + + try: + return MongoConnection.get_collection("api_activity_logs") + except Exception as e: + logger.error(f"Error getting MongoDB collection: {str(e)}") + log_exception(e) + return None + + +def safe_decode_body(content: bytes) -> Optional[str]: + """ + Safely decodes request/response body content, handling binary data. + Returns "[Binary Content]" if the content is binary, or a string representation of the content. + Returns None if the content is None or empty. + """ + # If the content is None, return None + if content is None: + return None + + # If the content is an empty bytes object, return None + if content == b"": + return None + + # Check if content is binary by looking for common binary file signatures + if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"): + return "[Binary Content]" + + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return "[Could not decode content]" + + +def log_to_mongo(log_document: Dict[str, Any]) -> bool: + """ + Logs the request to MongoDB if available. + """ + mongo_collection = get_mongo_collection() + if mongo_collection is None: + logger.error("MongoDB not configured") + return False + + try: + mongo_collection.insert_one(log_document) + return True + except Exception as e: + log_exception(e) + return False + + +def log_to_postgres(log_data: Dict[str, Any]) -> bool: + """ + Fallback to logging to PostgreSQL if MongoDB is unavailable. + """ + try: + APIActivityLog.objects.create(**log_data) + return True + except Exception as e: + log_exception(e) + return False + + +@shared_task +def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None: + """ + Process logs to save to MongoDB or Postgres based on the configuration + """ + + if MongoConnection.is_configured(): + log_to_mongo(mongo_log) + else: + log_to_postgres(log_data) diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py index 2ba0066eae..1d92820497 100644 --- a/apps/api/plane/db/mixins.py +++ b/apps/api/plane/db/mixins.py @@ -237,3 +237,30 @@ class ChangeTrackerMixin: all non-deferred fields). """ return self._original_values + + def save(self, *args: Any, **kwargs: Any) -> None: + """ + Override save to automatically capture changed fields and reset tracking. + + Before saving, the current changed_fields are captured and stored in + _changes_on_save. After saving, the tracked fields are reset so + that subsequent saves correctly detect changes relative to the last + saved state, not the original load-time state. + + Models that need to access the changed fields after save (e.g., for + syncing related models) can use self._changes_on_save. + """ + self._changes_on_save = self.changed_fields + super().save(*args, **kwargs) + self._reset_tracked_fields() + + def _reset_tracked_fields(self) -> None: + """ + Reset the tracked field values to the current state. + + This is called automatically after save() to ensure that subsequent + saves correctly detect changes relative to the last saved state, + rather than the original load-time state. + """ + self._original_values = {} + self._track_fields() diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 815cd83ba1..af9df79782 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -570,10 +570,12 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel): "comment_json": "description_json", } + # Use _changes_on_save which is captured by ChangeTrackerMixin.save() + # before the tracked fields are reset changed_fields = { desc_field: getattr(self, comment_field) for comment_field, desc_field in field_mapping.items() - if self.has_changed(comment_field) + if comment_field in self._changes_on_save } # Update description only if comment fields changed diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py index d513ee3e36..07facdab0e 100644 --- a/apps/api/plane/middleware/logger.py +++ b/apps/api/plane/middleware/logger.py @@ -4,13 +4,15 @@ import time # Django imports from django.http import HttpRequest +from django.utils import timezone # Third party imports from rest_framework.request import Request # Module imports from plane.utils.ip_address import get_client_ip -from plane.db.models import APIActivityLog +from plane.utils.exception_logger import log_exception +from plane.bgtasks.logger_task import process_logs api_logger = logging.getLogger("plane.api.request") @@ -70,6 +72,10 @@ class RequestLoggerMiddleware: class APITokenLogMiddleware: + """ + Middleware to log External API requests to MongoDB or PostgreSQL. + """ + def __init__(self, get_response): self.get_response = get_response @@ -104,24 +110,41 @@ class APITokenLogMiddleware: def process_request(self, request, response, request_body): api_key_header = "X-Api-Key" api_key = request.headers.get(api_key_header) - # If the API key is present, log the request - if api_key: - try: - APIActivityLog.objects.create( - token_identifier=api_key, - path=request.path, - method=request.method, - query_params=request.META.get("QUERY_STRING", ""), - headers=str(request.headers), - body=(self._safe_decode_body(request_body) if request_body else None), - response_body=(self._safe_decode_body(response.content) if response.content else None), - response_code=response.status_code, - ip_address=get_client_ip(request=request), - user_agent=request.META.get("HTTP_USER_AGENT", None), - ) - except Exception as e: - api_logger.exception(e) - # If the token does not exist, you can decide whether to log this as an invalid attempt + # If the API key is not present, return + if not api_key: + return + + try: + log_data = { + "token_identifier": api_key, + "path": request.path, + "method": request.method, + "query_params": request.META.get("QUERY_STRING", ""), + "headers": str(request.headers), + "body": self._safe_decode_body(request_body) if request_body else None, + "response_body": self._safe_decode_body(response.content) if response.content else None, + "response_code": response.status_code, + "ip_address": get_client_ip(request=request), + "user_agent": request.META.get("HTTP_USER_AGENT", None), + } + user_id = ( + str(request.user.id) + if getattr(request, "user") and getattr(request.user, "is_authenticated", False) + else None + ) + # Additional fields for MongoDB + mongo_log = { + **log_data, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "created_by": user_id, + "updated_by": user_id, + } + + process_logs.delay(log_data=log_data, mongo_log=mongo_log) + + except Exception as e: + log_exception(e) return None diff --git a/apps/live/package.json b/apps/live/package.json index 73fc45c218..201153a277 100644 --- a/apps/live/package.json +++ b/apps/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0", "description": "A realtime collaborative server powers Plane's rich text editor", "main": "./dist/start.mjs", diff --git a/apps/space/package.json b/apps/space/package.json index 2b9055e926..8ad083d52f 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "1.1.0", + "version": "1.2.0", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx index 7d8cfa82a8..15ce00a165 100644 --- a/apps/web/app/(all)/create-workspace/page.tsx +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -18,7 +18,7 @@ import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web helpers import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; -function CreateWorkspacePage() { +const CreateWorkspacePage = observer(function CreateWorkspacePage() { const { t } = useTranslation(); // router const router = useAppRouter(); @@ -104,6 +104,6 @@ function CreateWorkspacePage() { ); -} +}); -export default observer(CreateWorkspacePage); +export default CreateWorkspacePage; diff --git a/apps/web/ce/components/gantt-chart/index.ts b/apps/web/ce/components/gantt-chart/index.ts index d08e0f7d61..d652e4bbce 100644 --- a/apps/web/ce/components/gantt-chart/index.ts +++ b/apps/web/ce/components/gantt-chart/index.ts @@ -1 +1,2 @@ export * from "./dependency"; +export * from "./layers"; diff --git a/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx b/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx new file mode 100644 index 0000000000..5cc7c2ce5e --- /dev/null +++ b/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx @@ -0,0 +1,8 @@ +import type { FC } from "react"; + +type Props = { + itemsContainerWidth: number; + blockCount: number; +}; + +export const GanttAdditionalLayers: FC = () => null; diff --git a/apps/web/ce/components/gantt-chart/layers/index.ts b/apps/web/ce/components/gantt-chart/layers/index.ts new file mode 100644 index 0000000000..fcd77a972c --- /dev/null +++ b/apps/web/ce/components/gantt-chart/layers/index.ts @@ -0,0 +1 @@ +export { GanttAdditionalLayers } from "./additional-layers"; diff --git a/apps/web/core/components/core/description-versions/modal.tsx b/apps/web/core/components/core/description-versions/modal.tsx index fbb04b5c09..b393c80311 100644 --- a/apps/web/core/components/core/description-versions/modal.tsx +++ b/apps/web/core/components/core/description-versions/modal.tsx @@ -59,13 +59,12 @@ export const DescriptionVersionsModal = observer(function DescriptionVersionsMod const handleCopyMarkdown = useCallback(() => { if (!editorRef.current) return; - copyTextToClipboard(editorRef.current.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("toast.success"), - message: "Markdown copied to clipboard.", - }) - ); + editorRef.current.copyMarkdownToClipboard(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("toast.success"), + message: "Markdown copied to clipboard.", + }); }, [t]); if (!workspaceId) return null; diff --git a/apps/web/core/components/gantt-chart/chart/main-content.tsx b/apps/web/core/components/gantt-chart/chart/main-content.tsx index f555f90abd..bfa9d5fc39 100644 --- a/apps/web/core/components/gantt-chart/chart/main-content.tsx +++ b/apps/web/core/components/gantt-chart/chart/main-content.tsx @@ -17,7 +17,11 @@ import { GanttChartSidebar, MonthChartView, QuarterChartView, WeekChartView } fr // hooks import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // plane web components -import { TimelineDependencyPaths, TimelineDraggablePath } from "@/plane-web/components/gantt-chart"; +import { + TimelineDependencyPaths, + TimelineDraggablePath, + GanttAdditionalLayers, +} from "@/plane-web/components/gantt-chart"; import { GanttChartRowList } from "@/plane-web/components/gantt-chart/blocks/block-row-list"; import { GanttChartBlocksList } from "@/plane-web/components/gantt-chart/blocks/blocks-list"; import { IssueBulkOperationsRoot } from "@/plane-web/components/issues/bulk-operations"; @@ -212,6 +216,7 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro /> + { if (isSubmitting) return; - await workspaceService - .workspaceSlugCheck(formData.slug) - .then(async (res) => { - if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { - setSlugError(false); - await createWorkspace(formData) - .then(async (workspaceResponse) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("workspace_creation.toast.success.title"), - message: t("workspace_creation.toast.success.message"), - }); - captureSuccess({ - eventName: WORKSPACE_TRACKER_EVENTS.create, - payload: { slug: formData.slug }, - }); - await fetchWorkspaces(); - await completeStep(workspaceResponse.id); - onComplete(formData.organization_size === "Just myself"); - }) - .catch(() => { - captureError({ - eventName: WORKSPACE_TRACKER_EVENTS.create, - payload: { slug: formData.slug }, - error: new Error("Error creating workspace"), - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: t("workspace_creation.toast.error.title"), - message: t("workspace_creation.toast.error.message"), - }); - }); - } else setSlugError(true); - }) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: t("workspace_creation.toast.error.title"), - message: t("workspace_creation.toast.error.message"), - }) - ); + try { + const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean }; + if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(false); + try { + const workspaceResponse = await createWorkspace(formData); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("workspace_creation.toast.success.title"), + message: t("workspace_creation.toast.success.message"), + }); + captureSuccess({ + eventName: WORKSPACE_TRACKER_EVENTS.create, + payload: { slug: formData.slug }, + }); + await fetchWorkspaces(); + await completeStep(workspaceResponse.id); + onComplete(formData.organization_size === "Just myself"); + } catch { + captureError({ + eventName: WORKSPACE_TRACKER_EVENTS.create, + payload: { slug: formData.slug }, + error: new Error("Error creating workspace"), + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_creation.toast.error.title"), + message: t("workspace_creation.toast.error.message"), + }); + } + } else { + setSlugError(true); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_creation.toast.error.title"), + message: t("workspace_creation.toast.error.message"), + }); + } }; const completeStep = async (workspaceId: string) => { @@ -136,7 +135,12 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ ); } return ( -
+ { + void handleSubmit(handleCreateWorkspace)(e); + }} + >
@@ -181,6 +185,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ "border-red-500": errors.name, } )} + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus />
diff --git a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 572f0babec..5655469ccd 100644 --- a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: key: "copy-markdown", action: () => { if (!editorRef) return; - copyTextToClipboard(editorRef.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Markdown copied to clipboard.", - }) - ); + editorRef.copyMarkdownToClipboard(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Markdown copied to clipboard.", + }); }, title: "Copy markdown", icon: Clipboard, diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index 4a85cd03e8..028feddf85 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -67,47 +67,46 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: } = useForm({ defaultValues, mode: "onChange" }); const handleCreateWorkspace = async (formData: IWorkspace) => { - await workspaceService - .workspaceSlugCheck(formData.slug) - .then(async (res) => { - if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { - setSlugError(false); + try { + const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean }; + if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(false); - await createWorkspace(formData) - .then(async (res) => { - captureSuccess({ - eventName: WORKSPACE_TRACKER_EVENTS.create, - payload: { slug: formData.slug }, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("workspace_creation.toast.success.title"), - message: t("workspace_creation.toast.success.message"), - }); + try { + const workspaceResponse = await createWorkspace(formData); + captureSuccess({ + eventName: WORKSPACE_TRACKER_EVENTS.create, + payload: { slug: formData.slug }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("workspace_creation.toast.success.title"), + message: t("workspace_creation.toast.success.message"), + }); - if (onSubmit) await onSubmit(res); - }) - .catch(() => { - captureError({ - eventName: WORKSPACE_TRACKER_EVENTS.create, - payload: { slug: formData.slug }, - error: new Error("Error creating workspace"), - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: t("workspace_creation.toast.error.title"), - message: t("workspace_creation.toast.error.message"), - }); - }); - } else setSlugError(true); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("workspace_creation.toast.error.title"), - message: t("workspace_creation.toast.error.message"), - }); + if (onSubmit) await onSubmit(workspaceResponse); + } catch { + captureError({ + eventName: WORKSPACE_TRACKER_EVENTS.create, + payload: { slug: formData.slug }, + error: new Error("Error creating workspace"), + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_creation.toast.error.title"), + message: t("workspace_creation.toast.error.message"), + }); + } + } else { + setSlugError(true); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_creation.toast.error.title"), + message: t("workspace_creation.toast.error.message"), }); + } }; useEffect( @@ -119,7 +118,12 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: ); return ( - + { + void handleSubmit(handleCreateWorkspace)(e); + }} + >
+ +
+

{t("workspace_settings.settings.general.workspace_timezone")}

+ ( + <> + + + )} + /> +
{isAdmin && ( @@ -277,7 +302,9 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {