From ad04910209e4d78152a97c2aa980a85d9a946ee6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 30 Jul 2026 07:35:23 +0300 Subject: [PATCH] =?UTF-8?q?ARCH=20-=20TASKER=20BIM:=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20CAD-=D1=88=D0=BB=D1=8E?= =?UTF-8?q?=D0=B7=20=D0=B8=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=20=D0=B8=D0=B7=20Ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plane-app/docker-compose.local-web-build.yaml | 7 + plane-src/apps/api/plane/app/urls/issue.py | 30 ++ .../apps/api/plane/app/views/__init__.py | 8 + .../api/plane/app/views/issue/attachment.py | 107 ++-- .../plane/app/views/issue/bim_attachment.py | 465 ++++++++++++++++++ .../unit/utils/test_nodedc_bim_gateway.py | 102 ++++ .../api/plane/utils/nodedc_bim_gateway.py | 420 ++++++++++++++++ .../attachment/attachment-list-item.tsx | 165 +++---- .../issue/issue_attachment.service.ts | 236 ++++----- plane-src/apps/web/helpers/beam-viewer.ts | 211 +------- 10 files changed, 1252 insertions(+), 499 deletions(-) create mode 100644 plane-src/apps/api/plane/app/views/issue/bim_attachment.py create mode 100644 plane-src/apps/api/plane/tests/unit/utils/test_nodedc_bim_gateway.py create mode 100644 plane-src/apps/api/plane/utils/nodedc_bim_gateway.py diff --git a/plane-app/docker-compose.local-web-build.yaml b/plane-app/docker-compose.local-web-build.yaml index 02eb7df..a0b5aa0 100644 --- a/plane-app/docker-compose.local-web-build.yaml +++ b/plane-app/docker-compose.local-web-build.yaml @@ -1,4 +1,11 @@ services: + api: + image: nodedc/plane-backend:bim-gateway-test + environment: + PLANE_NODEDC_BIM_ACCESS_TOKEN: local-tasker-bim-gateway-test + PLANE_NODEDC_BIM_EMBED_SECRET: local-tasker-bim-gateway-test + PLANE_NODEDC_BIM_INTERNAL_URL: http://host.docker.internal:8080 + PLANE_NODEDC_BIM_PUBLIC_URL: http://localhost:8080 web: volumes: - ./.local-web-root:/usr/share/nginx/html:ro diff --git a/plane-src/apps/api/plane/app/urls/issue.py b/plane-src/apps/api/plane/app/urls/issue.py index 436d227..511290c 100644 --- a/plane-src/apps/api/plane/app/urls/issue.py +++ b/plane-src/apps/api/plane/app/urls/issue.py @@ -26,6 +26,11 @@ from plane.app.views import ( IssuePaginatedViewSet, IssueDetailEndpoint, IssueAttachmentV2Endpoint, + IssueBimAttachmentStatusEndpoint, + IssueBimAttachmentUploadEndpoint, + IssueBimAttachmentVersionDetailEndpoint, + IssueBimAttachmentVersionsEndpoint, + IssueBimAttachmentViewerEndpoint, IssueBulkUpdateDateEndpoint, IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint, @@ -144,6 +149,31 @@ urlpatterns = [ IssueAttachmentV2Endpoint.as_view(), name="project-issue-attachments", ), + path( + "assets/v2/workspaces//projects//issues//attachments/bim-upload/", + IssueBimAttachmentUploadEndpoint.as_view(), + name="project-issue-bim-attachment-upload", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//bim-status/", + IssueBimAttachmentStatusEndpoint.as_view(), + name="project-issue-bim-attachment-status", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//bim-versions/", + IssueBimAttachmentVersionsEndpoint.as_view(), + name="project-issue-bim-attachment-versions", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//bim-versions//", + IssueBimAttachmentVersionDetailEndpoint.as_view(), + name="project-issue-bim-attachment-version-detail", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//bim-viewer/", + IssueBimAttachmentViewerEndpoint.as_view(), + name="project-issue-bim-attachment-viewer", + ), ## End Issues ## Issue Activity path( diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index a453c78..bd7e541 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -145,6 +145,14 @@ from .issue.attachment import ( IssueAttachmentV2Endpoint, ) +from .issue.bim_attachment import ( + IssueBimAttachmentStatusEndpoint, + IssueBimAttachmentUploadEndpoint, + IssueBimAttachmentVersionDetailEndpoint, + IssueBimAttachmentVersionsEndpoint, + IssueBimAttachmentViewerEndpoint, +) + from .issue.comment import IssueCommentViewSet, CommentReactionViewSet from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint diff --git a/plane-src/apps/api/plane/app/views/issue/attachment.py b/plane-src/apps/api/plane/app/views/issue/attachment.py index 00b9c9b..949473a 100644 --- a/plane-src/apps/api/plane/app/views/issue/attachment.py +++ b/plane-src/apps/api/plane/app/views/issue/attachment.py @@ -27,6 +27,7 @@ from plane.utils.host import base_host from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing +from plane.utils.nodedc_bim_gateway import BimGatewayError, bim_gateway_request, get_bim_registry_identity class IssueAttachmentEndpoint(BaseAPIView): @@ -109,12 +110,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView): name = request.data.get("name") type = request.data.get("type", False) size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) - beam_viewer = request.data.get("beamViewer") - is_beam_viewer_reference = ( - isinstance(beam_viewer, dict) - and beam_viewer.get("src") - and beam_viewer.get("downloadUrl") - ) if not type or type not in settings.ATTACHMENT_MIME_TYPES: return Response( @@ -126,45 +121,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(id=project_id, workspace=workspace) - if is_beam_viewer_reference: - asset = FileAsset.objects.create( - attributes={ - "name": name, - "type": type, - "size": size, - "beamViewer": beam_viewer, - }, - asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}", - size=0, - workspace_id=workspace.id, - created_by=request.user, - issue_id=issue_id, - project_id=project_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - is_uploaded=True, - external_source="beam-viewer", - ) - serializer = IssueAttachmentSerializer(asset) - issue_activity.delay( - type="attachment.activity.created", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - return Response( - { - "asset_id": str(asset.id), - "attachment": serializer.data, - "asset_url": asset.asset_url, - }, - status=status.HTTP_200_OK, - ) - # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" @@ -205,7 +161,32 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + issue_attachment = FileAsset.objects.select_related("created_by").get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + ) + attributes = issue_attachment.attributes if isinstance(issue_attachment.attributes, dict) else {} + beam_viewer = attributes.get("beamViewer") + if issue_attachment.external_source == "beam-viewer" and isinstance(beam_viewer, dict): + try: + bim_gateway_request( + "DELETE", + "/api/uploads/asset", + identity=get_bim_registry_identity(issue_attachment, beam_viewer), + json_payload=beam_viewer, + ) + except BimGatewayError as error: + return Response( + { + "ok": False, + "error": error.code, + "message": error.message, + **({"upstream_status": error.upstream_status} if error.upstream_status else {}), + }, + status=error.status_code, + ) if not issue_attachment.is_uploaded: release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True) issue_attachment.is_deleted = True @@ -256,28 +237,22 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def patch(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + issue_attachment = FileAsset.objects.get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) beam_viewer = request.data.get("beamViewer") if isinstance(beam_viewer, dict): - attributes = issue_attachment.attributes or {} - existing_beam_viewer = attributes.get("beamViewer") - if not isinstance(existing_beam_viewer, dict): - return Response( - {"error": "The attachment is not a BIM Viewer reference.", "status": False}, - status=status.HTTP_400_BAD_REQUEST, - ) - - attributes["beamViewer"] = { - **existing_beam_viewer, - **beam_viewer, - } - for attribute_key in ("name", "size", "type", "version"): - if attribute_key in request.data: - attributes[attribute_key] = request.data.get(attribute_key) - issue_attachment.attributes = attributes - issue_attachment.is_uploaded = True - issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"]) - return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK) + return Response( + { + "error": "BIM attachments are managed by the Ops BIM gateway.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = IssueAttachmentSerializer(issue_attachment) if not attachment_object_exists(issue_attachment): diff --git a/plane-src/apps/api/plane/app/views/issue/bim_attachment.py b/plane-src/apps/api/plane/app/views/issue/bim_attachment.py new file mode 100644 index 0000000..6bde882 --- /dev/null +++ b/plane-src/apps/api/plane/app/views/issue/bim_attachment.py @@ -0,0 +1,465 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import json +import logging +import re +import uuid + +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone +from rest_framework import status +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response + +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import IssueAttachmentSerializer +from plane.app.views import BaseAPIView +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import FileAsset, Project, Workspace +from plane.utils.host import base_host +from plane.utils.nodedc_bim_gateway import ( + BimGatewayError, + bim_gateway_request, + build_bim_attachment, + build_bim_embed_url, + find_bim_version, + get_bim_model_mime_type, + get_bim_model_type, + get_bim_registry_identity, + make_bim_registry_owner_id, + merge_bim_versions, + resolve_bim_viewer_model, + to_bim_public_url, + to_bim_relative_asset_url, +) +from plane.utils.upload_limits import resolve_workspace_upload_size_limit + + +logger = logging.getLogger(__name__) + + +def _error_response(error): + return Response( + { + "ok": False, + "error": error.code, + "message": error.message, + **({"upstream_status": error.upstream_status} if error.upstream_status else {}), + }, + status=error.status_code, + ) + + +def _get_attachment(slug, project_id, issue_id, attachment_id): + return ( + FileAsset.objects.select_related("created_by") + .filter( + id=attachment_id, + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + external_source="beam-viewer", + ) + .first() + ) + + +def _get_beam_viewer(attachment): + attributes = attachment.attributes if isinstance(attachment.attributes, dict) else {} + beam_viewer = attributes.get("beamViewer") + return beam_viewer if isinstance(beam_viewer, dict) else None + + +def _upload_group_id(slug, project_id, issue_id): + value = "{}_{}_{}".format(slug, project_id, issue_id) + return re.sub(r"[^a-zA-Z0-9_-]", "_", value) or "tasker" + + +def _validate_upload(workspace, uploaded_file): + if uploaded_file is None: + return Response( + {"ok": False, "error": "bim_file_required", "message": "A BIM model file is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not get_bim_model_type(uploaded_file.name): + return Response( + { + "ok": False, + "error": "bim_format_not_supported", + "message": "This BIM model format is not supported.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + size_limit = resolve_workspace_upload_size_limit(workspace, uploaded_file.size) + if uploaded_file.size > size_limit: + return Response( + { + "ok": False, + "error": "bim_file_too_large", + "message": "The BIM model exceeds the workspace upload limit.", + "limit": size_limit, + }, + status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + return None + + +def _activity_created(request, serializer, issue_id, project_id): + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + +def _delete_uploaded_asset(beam_viewer, identity): + try: + bim_gateway_request( + "DELETE", + "/api/uploads/asset", + identity=identity, + json_payload=beam_viewer, + ) + except BimGatewayError: + logger.exception("Failed to clean up BIM asset after an OPS attachment failure") + + +def _delete_uploaded_version(version, identity): + try: + bim_gateway_request( + "DELETE", + "/api/uploads/version", + identity=identity, + json_payload=version, + ) + except BimGatewayError: + logger.exception("Failed to clean up BIM version after an OPS attachment failure") + + +def _public_status_payload(payload): + result = dict(payload) + result["artifactUrl"] = to_bim_public_url(payload.get("artifactSrc")) + result["metadataUrl"] = to_bim_public_url(payload.get("metadataSrc")) + return result + + +def _public_version(version): + result = dict(version) + for key in ("src", "sourceSrc", "downloadUrl"): + if result.get(key): + result[key] = to_bim_public_url(result[key]) + result["viewerUrl"] = None + return result + + +class IssueBimAttachmentUploadEndpoint(BaseAPIView): + parser_classes = (MultiPartParser, FormParser) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id, issue_id): + workspace = Workspace.objects.get(slug=slug) + Project.objects.get(id=project_id, workspace=workspace) + uploaded_file = request.FILES.get("file") + validation_error = _validate_upload(workspace, uploaded_file) + if validation_error is not None: + return validation_error + + attachment_id = uuid.uuid4() + registry_owner_id = make_bim_registry_owner_id(attachment_id) + identity = {"id": registry_owner_id, "email": request.user.email or ""} + uploaded_file.seek(0) + try: + upload_payload = bim_gateway_request( + "POST", + "/api/uploads", + identity=identity, + params={ + "filename": uploaded_file.name, + "projectId": _upload_group_id(slug, project_id, issue_id), + "assetId": str(attachment_id), + "version": "1", + }, + data=uploaded_file.file, + content_length=uploaded_file.size, + ) + beam_viewer = build_bim_attachment( + upload_payload, + filename=uploaded_file.name, + size=uploaded_file.size, + uploaded_by=request.user.id, + registry_owner_id=registry_owner_id, + ) + except BimGatewayError as error: + return _error_response(error) + + try: + asset = FileAsset.objects.create( + id=attachment_id, + attributes={ + "name": uploaded_file.name, + "type": get_bim_model_mime_type(uploaded_file.name), + "size": uploaded_file.size, + "version": beam_viewer.get("version") or 1, + "beamViewer": beam_viewer, + }, + asset="{}/beam-viewer/{}-{}".format(workspace.id, attachment_id.hex, uploaded_file.name), + size=0, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + external_source="beam-viewer", + ) + serializer = IssueAttachmentSerializer(asset) + _activity_created(request, serializer, issue_id, project_id) + except Exception: + _delete_uploaded_asset(beam_viewer, identity) + raise + + return Response( + { + "asset_id": str(asset.id), + "attachment": serializer.data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_201_CREATED, + ) + + +class IssueBimAttachmentStatusEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk): + attachment = _get_attachment(slug, project_id, issue_id, pk) + if attachment is None: + return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND) + beam_viewer = _get_beam_viewer(attachment) + if beam_viewer is None: + return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT) + source = to_bim_relative_asset_url(beam_viewer.get("src")) + if not source: + return Response({"error": "BIM attachment source is invalid."}, status=status.HTTP_422_UNPROCESSABLE_ENTITY) + try: + payload = bim_gateway_request( + "GET", + "/api/conversions/status", + identity=get_bim_registry_identity(attachment, beam_viewer), + params={"src": source}, + ) + except BimGatewayError as error: + return _error_response(error) + return Response(_public_status_payload(payload), status=status.HTTP_200_OK) + + +class IssueBimAttachmentVersionsEndpoint(BaseAPIView): + parser_classes = (MultiPartParser, FormParser) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk): + attachment = _get_attachment(slug, project_id, issue_id, pk) + if attachment is None: + return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND) + beam_viewer = _get_beam_viewer(attachment) + if beam_viewer is None: + return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT) + params = { + "projectId": beam_viewer.get("projectId"), + "assetId": beam_viewer.get("assetId"), + } + if not all(params.values()): + params = {"src": to_bim_relative_asset_url(beam_viewer.get("src"))} + try: + payload = bim_gateway_request( + "GET", + "/api/uploads/versions", + identity=get_bim_registry_identity(attachment, beam_viewer), + params=params, + ) + except BimGatewayError as error: + return _error_response(error) + result = dict(payload) + result["versions"] = [_public_version(version) for version in payload.get("versions") or []] + return Response(result, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id, issue_id, pk): + attachment = _get_attachment(slug, project_id, issue_id, pk) + if attachment is None: + return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND) + beam_viewer = _get_beam_viewer(attachment) + if beam_viewer is None: + return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT) + + workspace = Workspace.objects.get(slug=slug) + uploaded_file = request.FILES.get("file") + validation_error = _validate_upload(workspace, uploaded_file) + if validation_error is not None: + return validation_error + + versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else [] + current_versions = [int(beam_viewer.get("version") or 1)] + current_versions.extend(int(version.get("version") or 0) for version in versions) + next_version = max(current_versions) + 1 + identity = get_bim_registry_identity(attachment, beam_viewer) + uploaded_file.seek(0) + try: + upload_payload = bim_gateway_request( + "POST", + "/api/uploads", + identity=identity, + params={ + "filename": uploaded_file.name, + "projectId": beam_viewer.get("projectId") or _upload_group_id(slug, project_id, issue_id), + "assetId": beam_viewer.get("assetId") or str(attachment.id), + "version": str(next_version), + }, + data=uploaded_file.file, + content_length=uploaded_file.size, + ) + next_beam_viewer = build_bim_attachment( + upload_payload, + filename=uploaded_file.name, + size=uploaded_file.size, + uploaded_by=request.user.id, + registry_owner_id=identity["id"], + ) + next_beam_viewer["versions"] = merge_bim_versions(beam_viewer, next_beam_viewer) + except BimGatewayError as error: + return _error_response(error) + + next_record = find_bim_version(next_beam_viewer, next_beam_viewer.get("versionId")) + try: + attributes = dict(attachment.attributes or {}) + attributes.update( + { + "name": uploaded_file.name, + "type": get_bim_model_mime_type(uploaded_file.name), + "size": uploaded_file.size, + "version": next_beam_viewer.get("version") or next_version, + "beamViewer": next_beam_viewer, + } + ) + attachment.attributes = attributes + attachment.save(update_fields=["attributes", "updated_at"]) + except Exception: + if next_record: + _delete_uploaded_version(next_record, identity) + raise + return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK) + + +class IssueBimAttachmentVersionDetailEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def delete(self, request, slug, project_id, issue_id, pk, version_id): + attachment = _get_attachment(slug, project_id, issue_id, pk) + if attachment is None: + return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND) + beam_viewer = _get_beam_viewer(attachment) + if beam_viewer is None: + return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT) + version = find_bim_version(beam_viewer, version_id) + if version is None: + return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND) + current_version_id = beam_viewer.get("versionId") + if (current_version_id and current_version_id == version.get("versionId")) or ( + not current_version_id and beam_viewer.get("version") == version.get("version") + ): + return Response( + {"error": "The current BIM version cannot be deleted."}, + status=status.HTTP_409_CONFLICT, + ) + try: + bim_gateway_request( + "DELETE", + "/api/uploads/version", + identity=get_bim_registry_identity(attachment, beam_viewer), + json_payload=version, + ) + except BimGatewayError as error: + return _error_response(error) + + versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else [] + beam_viewer = { + **beam_viewer, + "versions": [ + candidate + for candidate in versions + if not ( + (version.get("versionId") and candidate.get("versionId") == version.get("versionId")) + or (not version.get("versionId") and candidate.get("version") == version.get("version")) + ) + ], + } + attributes = dict(attachment.attributes or {}) + attributes["beamViewer"] = beam_viewer + attachment.attributes = attributes + attachment.save(update_fields=["attributes", "updated_at"]) + return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK) + + +class IssueBimAttachmentViewerEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, issue_id, pk): + attachment = _get_attachment(slug, project_id, issue_id, pk) + if attachment is None: + return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND) + beam_viewer = _get_beam_viewer(attachment) + if beam_viewer is None: + return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT) + + version_id = request.data.get("versionId") + version = find_bim_version(beam_viewer, version_id) + if version is None: + return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND) + + live_status = None + conversion = version.get("conversion") if isinstance(version.get("conversion"), dict) else {} + if conversion and conversion.get("status") != "ready": + source = to_bim_relative_asset_url(version.get("sourceSrc") or version.get("src")) + if source: + try: + live_status = _public_status_payload( + bim_gateway_request( + "GET", + "/api/conversions/status", + identity=get_bim_registry_identity(attachment, beam_viewer), + params={"src": source}, + ) + ) + except BimGatewayError as error: + return _error_response(error) + try: + model = resolve_bim_viewer_model( + beam_viewer, + version_id=version_id, + live_status=live_status, + ) + viewer_url, expires_at = build_bim_embed_url( + model, + request.user, + slug, + project_id, + issue_id, + attachment.id, + ) + except BimGatewayError as error: + return _error_response(error) + return Response( + { + "ok": True, + "viewerUrl": viewer_url, + "expiresAt": expires_at, + }, + status=status.HTTP_200_OK, + ) diff --git a/plane-src/apps/api/plane/tests/unit/utils/test_nodedc_bim_gateway.py b/plane-src/apps/api/plane/tests/unit/utils/test_nodedc_bim_gateway.py new file mode 100644 index 0000000..234807e --- /dev/null +++ b/plane-src/apps/api/plane/tests/unit/utils/test_nodedc_bim_gateway.py @@ -0,0 +1,102 @@ +# 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 json +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest + +from plane.utils.nodedc_bim_gateway import ( + BimGatewayError, + build_bim_embed_url, + get_bim_direct_model_type, + get_bim_model_type, + resolve_bim_viewer_model, + to_bim_relative_asset_url, +) + + +@pytest.mark.unit +def test_cad_extensions_are_normalized(): + assert get_bim_model_type("part.STEP") == "step" + assert get_bim_model_type("part.stp") == "step" + assert get_bim_model_type("part.IGES") == "iges" + assert get_bim_model_type("part.igs") == "iges" + assert get_bim_direct_model_type("part.glb") == "gltf" + assert get_bim_direct_model_type("part.step") is None + + +@pytest.mark.unit +def test_only_managed_public_bim_sources_are_accepted(monkeypatch): + monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example") + + assert ( + to_bim_relative_asset_url("https://bim.example/uploads/project/model.glb?version=2") + == "/uploads/project/model.glb?version=2" + ) + assert to_bim_relative_asset_url("/data/project/model.xkt") == "/data/project/model.xkt" + assert to_bim_relative_asset_url("https://attacker.example/uploads/model.glb") is None + assert to_bim_relative_asset_url("/api/auth/session") is None + + +@pytest.mark.unit +def test_embed_grant_is_short_lived_and_bound_to_ops_context(monkeypatch): + monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example") + monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_SECRET", "test-embed-secret") + monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180") + model = { + "url": "/uploads/project/model.glb", + "settingsSrc": "/uploads/project/model.glb", + "type": "gltf", + "name": "model.glb", + } + + viewer_url, expires_at = build_bim_embed_url( + model, + SimpleNamespace(id="user-1"), + "workspace", + "project-1", + "issue-1", + "attachment-1", + ) + + query = parse_qs(urlsplit(viewer_url).query) + encoded_payload = query["token"][0].split(".", 1)[0] + encoded_payload += "=" * (-len(encoded_payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(encoded_payload).decode("utf-8")) + assert urlsplit(viewer_url).path == "/embed/tasker" + assert claims["sub"] == "user-1" + assert claims["workspace"] == "workspace" + assert claims["project"] == "project-1" + assert claims["issue"] == "issue-1" + assert claims["attachment"] == "attachment-1" + assert claims["model"] == model + assert expires_at - claims["iat"] == 180 + + +@pytest.mark.unit +def test_viewer_model_rejects_unmanaged_or_unready_sources(monkeypatch): + monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example") + + with pytest.raises(BimGatewayError, match="still being prepared"): + resolve_bim_viewer_model( + { + "originalFilename": "part.step", + "previewAvailable": False, + "src": "https://bim.example/uploads/project/part.step", + "type": "step", + } + ) + + with pytest.raises(BimGatewayError, match="outside the managed BIM storage"): + resolve_bim_viewer_model( + { + "originalFilename": "model.glb", + "previewAvailable": True, + "src": "https://attacker.example/uploads/model.glb", + "type": "gltf", + } + ) diff --git a/plane-src/apps/api/plane/utils/nodedc_bim_gateway.py b/plane-src/apps/api/plane/utils/nodedc_bim_gateway.py new file mode 100644 index 0000000..8702119 --- /dev/null +++ b/plane-src/apps/api/plane/utils/nodedc_bim_gateway.py @@ -0,0 +1,420 @@ +# 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 +import hmac +import json +import os +import time +from urllib.parse import urlencode, urljoin, urlsplit + +import requests + +BIM_EMBED_AUDIENCE = "nodedc-bim-tasker-embed" +BIM_EMBED_ISSUER = "nodedc-tasker" +BIM_SUPPORTED_MODEL_TYPES = { + "bim": "bim", + "glb": "gltf", + "gltf": "gltf", + "iges": "iges", + "igs": "iges", + "las": "las", + "laz": "las", + "obj": "obj", + "step": "step", + "stl": "stl", + "stp": "step", + "xkt": "xkt", +} +BIM_DIRECT_MODEL_TYPES = { + "bim": "bim", + "glb": "gltf", + "gltf": "gltf", + "las": "las", + "laz": "las", + "obj": "obj", + "stl": "stl", + "xkt": "xkt", +} + + +class BimGatewayError(Exception): + def __init__(self, code, message, status_code=502, upstream_status=None): + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.upstream_status = upstream_status + + +def get_bim_gateway_config(): + public_base_url = ( + os.environ.get("PLANE_NODEDC_BIM_PUBLIC_URL", "").strip() + or os.environ.get("NODEDC_BIM_PUBLIC_URL", "").strip() + or "https://bim.nodedc.tech" + ).rstrip("/") + internal_base_url = ( + os.environ.get("PLANE_NODEDC_BIM_INTERNAL_URL", "").strip() + or os.environ.get("PLANE_NODEDC_BIM_URL", "").strip() + or public_base_url + ).rstrip("/") + token = ( + os.environ.get("PLANE_NODEDC_BIM_ACCESS_TOKEN", "").strip() + or os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip() + or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip() + ) + embed_secret = ( + os.environ.get("PLANE_NODEDC_BIM_EMBED_SECRET", "").strip() + or os.environ.get("NODEDC_BIM_EMBED_SECRET", "").strip() + or token + ) + connect_timeout = float(os.environ.get("PLANE_NODEDC_BIM_CONNECT_TIMEOUT_SECONDS", "5") or "5") + read_timeout = float(os.environ.get("PLANE_NODEDC_BIM_READ_TIMEOUT_SECONDS", "180") or "180") + embed_ttl = max(30, int(os.environ.get("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180") or "180")) + return { + "public_base_url": public_base_url, + "internal_base_url": internal_base_url, + "token": token, + "embed_secret": embed_secret, + "timeout": (connect_timeout, read_timeout), + "embed_ttl": embed_ttl, + } + + +def get_bim_model_type(filename): + extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") + return BIM_SUPPORTED_MODEL_TYPES.get(extension) + + +def get_bim_direct_model_type(filename): + extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") + return BIM_DIRECT_MODEL_TYPES.get(extension) + + +def get_bim_model_mime_type(filename): + extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") + if extension == "glb": + return "model/gltf-binary" + if extension == "gltf": + return "model/gltf+json" + return "application/octet-stream" + + +def make_bim_registry_owner_id(attachment_id): + return "tasker-attachment:{}".format(attachment_id) + + +def get_bim_registry_identity(attachment, beam_viewer=None): + beam_viewer = beam_viewer if isinstance(beam_viewer, dict) else {} + registry_owner_id = beam_viewer.get("registryOwnerId") + if isinstance(registry_owner_id, str) and registry_owner_id.strip(): + return { + "id": registry_owner_id.strip(), + "email": "", + } + created_by = getattr(attachment, "created_by", None) + created_by_id = getattr(attachment, "created_by_id", None) + return { + "id": str(created_by_id or getattr(attachment, "id", "")), + "email": getattr(created_by, "email", "") or "", + } + + +def _gateway_headers(identity, content_type=None, content_length=None): + config = get_bim_gateway_config() + if not config["token"]: + raise BimGatewayError( + "bim_gateway_not_configured", + "NODE.DC BIM gateway token is not configured.", + status_code=503, + ) + identity = identity or {} + headers = { + "Authorization": "Bearer {}".format(config["token"]), + "Accept": "application/json", + "X-NODEDC-Service": "tasker", + "X-NODEDC-User-Id": str(identity.get("id") or "tasker"), + "X-NODEDC-User-Email": str(identity.get("email") or ""), + } + if content_type: + headers["Content-Type"] = content_type + if content_length is not None: + headers["Content-Length"] = str(max(0, int(content_length))) + return headers + + +def bim_gateway_request(method, path, identity=None, params=None, json_payload=None, data=None, content_length=None): + config = get_bim_gateway_config() + try: + response = requests.request( + method, + "{}{}".format(config["internal_base_url"], path), + params=params, + json=json_payload, + data=data, + headers=_gateway_headers( + identity, + content_type="application/octet-stream" + if data is not None + else ("application/json" if json_payload is not None else None), + content_length=content_length, + ), + timeout=config["timeout"], + ) + except requests.RequestException as exc: + raise BimGatewayError( + "bim_gateway_unavailable", + "NODE.DC BIM gateway is unavailable.", + status_code=502, + ) from exc + + try: + payload = response.json() + except ValueError: + payload = None + + if response.status_code >= 400: + upstream_message = payload.get("message") if isinstance(payload, dict) else None + upstream_error = payload.get("error") if isinstance(payload, dict) else None + status_code = response.status_code if response.status_code in {400, 404, 409, 413, 422} else 502 + raise BimGatewayError( + upstream_error or "bim_gateway_rejected", + upstream_message or "NODE.DC BIM gateway rejected the request.", + status_code=status_code, + upstream_status=response.status_code, + ) + if not isinstance(payload, dict): + raise BimGatewayError( + "bim_gateway_invalid_response", + "NODE.DC BIM gateway returned an invalid response.", + status_code=502, + upstream_status=response.status_code, + ) + return payload + + +def to_bim_public_url(value): + if not isinstance(value, str) or not value.strip(): + return None + config = get_bim_gateway_config() + raw_value = value.strip() + parsed = urlsplit(raw_value) + if parsed.scheme or parsed.netloc: + raw_value = parsed.path + if parsed.query: + raw_value = "{}?{}".format(raw_value, parsed.query) + return urljoin("{}/".format(config["public_base_url"]), raw_value.lstrip("/")) + + +def to_bim_relative_asset_url(value): + if not isinstance(value, str) or not value.strip(): + return None + config = get_bim_gateway_config() + raw_value = value.strip() + parsed = urlsplit(raw_value) + if parsed.scheme or parsed.netloc: + public_origin = urlsplit(config["public_base_url"]) + if (parsed.scheme, parsed.netloc) != (public_origin.scheme, public_origin.netloc): + return None + relative = parsed.path + if parsed.query: + relative = "{}?{}".format(relative, parsed.query) + else: + relative = "/{}".format(raw_value.lstrip("/")) + path_only = urlsplit(relative).path + if not (path_only.startswith("/uploads/") or path_only.startswith("/data/")): + return None + return relative + + +def _model_version_record(beam_viewer, size=0): + conversion = beam_viewer.get("conversion") if isinstance(beam_viewer.get("conversion"), dict) else None + return { + "assetId": beam_viewer.get("assetId"), + "conversion": conversion, + "downloadUrl": beam_viewer.get("downloadUrl"), + "originalFilename": beam_viewer.get("originalFilename"), + "previewAvailable": bool(beam_viewer.get("previewAvailable")), + "projectId": beam_viewer.get("projectId"), + "sha256": beam_viewer.get("sha256"), + "size": int(size or (conversion or {}).get("size") or 0), + "sourceSrc": (conversion or {}).get("sourceSrc") or beam_viewer.get("src"), + "src": beam_viewer.get("src"), + "status": (conversion or {}).get("status") or "ready", + "type": beam_viewer.get("type"), + "uploadedBy": beam_viewer.get("uploadedBy"), + "uploadedAt": beam_viewer.get("uploadedAt"), + "version": int(beam_viewer.get("version") or 1), + "versionId": beam_viewer.get("versionId"), + "viewerUrl": None, + } + + +def build_bim_attachment(upload_payload, filename, size, uploaded_by, registry_owner_id): + source_src = upload_payload.get("src") + if not isinstance(source_src, str) or not source_src: + raise BimGatewayError( + "bim_gateway_invalid_response", + "NODE.DC BIM gateway did not return an uploaded model path.", + status_code=502, + ) + model_type = get_bim_model_type(filename) + direct_type = get_bim_direct_model_type(filename) + conversion = upload_payload.get("conversion") + if isinstance(conversion, dict): + conversion = { + **conversion, + "componentTreeRequired": True, + "message": conversion.get("message") + or "Оригинальная CAD-модель загружена. Просмотр появится после подготовки модели и дерева компонентов.", + "sourceFormat": conversion.get("sourceFormat") or model_type, + "status": conversion.get("status") or "conversion_required", + "targetFormat": conversion.get("targetFormat") or "xkt", + } + else: + conversion = None + source_url = to_bim_public_url(source_src) + beam_viewer = { + "assetId": upload_payload.get("assetId"), + "backend": "beam-viewer-ops", + "downloadUrl": source_url, + "originalFilename": upload_payload.get("originalFilename") or filename, + "previewAvailable": bool(direct_type), + "projectId": upload_payload.get("projectId"), + "registryOwnerId": registry_owner_id, + "sha256": upload_payload.get("sha256"), + "src": source_url, + "type": model_type, + "uploadedBy": str(uploaded_by) if uploaded_by else None, + "uploadedAt": upload_payload.get("uploadedAt"), + "version": int(upload_payload.get("version") or 1), + "versionId": upload_payload.get("versionId"), + "viewerUrl": None, + } + if conversion: + beam_viewer["conversion"] = conversion + beam_viewer["versions"] = [_model_version_record(beam_viewer, size=size)] + return beam_viewer + + +def merge_bim_versions(previous_beam_viewer, next_beam_viewer): + versions = previous_beam_viewer.get("versions") + versions = list(versions) if isinstance(versions, list) else [] + if not versions: + versions.append(_model_version_record(previous_beam_viewer)) + next_versions = next_beam_viewer.get("versions") + next_record = ( + next_versions[0] + if isinstance(next_versions, list) and next_versions + else _model_version_record(next_beam_viewer) + ) + next_version_id = next_record.get("versionId") + next_version = next_record.get("version") + versions = [ + version + for version in versions + if not ( + (next_version_id and version.get("versionId") == next_version_id) + or (not next_version_id and version.get("version") == next_version) + ) + ] + versions.append(next_record) + return sorted(versions, key=lambda version: int(version.get("version") or 0)) + + +def find_bim_version(beam_viewer, version_id): + versions = beam_viewer.get("versions") + if not isinstance(versions, list): + versions = [] + if version_id: + for version in versions: + if version.get("versionId") == version_id or str(version.get("version")) == str(version_id): + return version + return None + return _model_version_record(beam_viewer) + + +def resolve_bim_viewer_model(beam_viewer, version_id=None, live_status=None): + record = find_bim_version(beam_viewer, version_id) + if not isinstance(record, dict): + raise BimGatewayError("bim_version_not_found", "BIM model version was not found.", status_code=404) + conversion = record.get("conversion") if isinstance(record.get("conversion"), dict) else {} + if live_status and isinstance(live_status, dict): + conversion = {**conversion, **live_status} + + if conversion.get("status") == "ready": + source = conversion.get("artifactUrl") or conversion.get("artifactSrc") + model_type = conversion.get("artifactType") or conversion.get("targetFormat") or "gltf" + settings_source = conversion.get("sourceSrc") or record.get("sourceSrc") or record.get("src") + elif record.get("previewAvailable"): + source = record.get("src") + model_type = record.get("type") + settings_source = record.get("sourceSrc") or record.get("src") + else: + raise BimGatewayError( + "bim_model_not_ready", + "BIM model preview is still being prepared.", + status_code=409, + ) + + source = to_bim_relative_asset_url(source) + settings_source = to_bim_relative_asset_url(settings_source) + if not source or not settings_source: + raise BimGatewayError( + "bim_model_source_invalid", + "BIM model source is outside the managed BIM storage.", + status_code=422, + ) + return { + "url": source, + "settingsSrc": settings_source, + "type": model_type, + "name": record.get("originalFilename") or beam_viewer.get("originalFilename") or "model", + } + + +def _base64url(value): + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def build_bim_embed_url(model, user, workspace_slug, project_id, issue_id, attachment_id): + config = get_bim_gateway_config() + if not config["embed_secret"]: + raise BimGatewayError( + "bim_embed_not_configured", + "NODE.DC BIM embed signing secret is not configured.", + status_code=503, + ) + now = int(time.time()) + payload = { + "v": 1, + "aud": BIM_EMBED_AUDIENCE, + "iss": BIM_EMBED_ISSUER, + "iat": now, + "exp": now + config["embed_ttl"], + "sub": str(getattr(user, "id", "")), + "workspace": str(workspace_slug), + "project": str(project_id), + "issue": str(issue_id), + "attachment": str(attachment_id), + "model": model, + } + encoded_payload = _base64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")) + signature = hmac.new( + config["embed_secret"].encode("utf-8"), + encoded_payload.encode("ascii"), + hashlib.sha256, + ).digest() + token = "{}.{}".format(encoded_payload, _base64url(signature)) + query = urlencode( + { + "token": token, + "url": model["url"], + "settingsSrc": model["settingsSrc"], + "type": model["type"], + "name": model["name"], + } + ) + return "{}/embed/tasker?{}".format(config["public_base_url"], query), payload["exp"] diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx index a0efbd0..3c43a20 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -27,16 +27,12 @@ import { getFileIcon } from "@/components/icons"; import { buildBeamViewerUrl, dispatchBeamViewerOpenEvent, - fetchBeamConversionStatus, - fetchBeamModelVersions, getBeamModelVersionRecords, getBeamVersionViewerUrl, getBeamViewerAttachment, isBeamModelFile, mergeBeamModelVersionRecordLists, - syncCurrentBeamVersionRecord, type TBeamModelVersionRecord, - type TBeamViewerAttachment, type TBeamConversionStatus, } from "@/helpers/beam-viewer"; import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview"; @@ -125,46 +121,6 @@ const sanitizeBeamStatusMessage = (message: string | undefined): string | undefi const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string => version.versionId || `version-${version.version}`; -const buildSyncedBeamViewerAttachment = ( - beamViewer: TBeamViewerAttachment, - status: TBeamConversionStatus, - fullFileName: string -): TBeamViewerAttachment | null => { - if (status.status !== "ready" || !status.artifactUrl) return null; - - const artifactType = status.artifactType || status.targetFormat || "gltf"; - const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb"); - - return syncCurrentBeamVersionRecord({ - ...beamViewer, - assetId: beamViewer.assetId || status.assetId, - previewAvailable: true, - sha256: beamViewer.sha256 || status.sha256, - version: beamViewer.version || status.version, - versionId: beamViewer.versionId || status.versionId, - viewerUrl: buildBeamViewerUrl({ - name: fullFileName, - settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src, - src: status.artifactUrl, - type: artifactType, - }), - conversion: { - ...beamViewer.conversion, - artifactSrc: status.artifactSrc, - artifactType, - componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true, - message: status.message || beamViewer.conversion?.message, - metadataSrc: status.metadataSrc, - size: status.size ?? beamViewer.conversion?.size, - sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type, - sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc, - status: "ready", - targetFormat, - updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt, - }, - }); -}; - export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { const { t } = useTranslation(); // props @@ -213,6 +169,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt type: beamConversionStatus?.artifactType || "gltf", }) : undefined) || storedModelViewerUrlWithSettings; + const canOpenModelViewer = + !!beamViewer && + (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl); const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const previewDownloadUrl = modelDownloadUrl || fileURL; const isBeamModel = isBeamModelFile(fullFileName); @@ -224,7 +183,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт." : null); const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage; - const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed; + const isBeamAwaitingPreview = !!beamViewer && !canOpenModelViewer && !isBeamConversionFailed; const rawBeamStatusTooltipContent = isBeamConversionFailed ? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов." : beamConversionStatus?.message || @@ -268,30 +227,57 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt if (!userId) return "—"; return getUserDetails(userId)?.display_name ?? "—"; }; - const openModelViewer = () => { - if (!modelViewerUrl) return; - dispatchBeamViewerOpenEvent({ - downloadUrl: modelDownloadUrl, - fileExtension, - fileName: fullFileName, - fileSize: attachment?.attributes.size ?? 0, - issueId, - viewerUrl: modelViewerUrl, - }); + const openModelViewer = async () => { + if (!canOpenModelViewer || !attachment) return; + try { + const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl( + workspaceSlug, + projectId, + issueId, + attachment.id + ); + dispatchBeamViewerOpenEvent({ + downloadUrl: modelDownloadUrl, + fileExtension, + fileName: fullFileName, + fileSize: attachment.attributes.size ?? 0, + issueId, + viewerUrl, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Модель не открыта", + message: error instanceof Error ? error.message : "Не удалось запустить BIM Viewer через Ops.", + }); + } }; - const openBeamVersionViewer = (version: TBeamModelVersionRecord) => { - const viewerUrl = getBeamVersionViewerUrl(version); - if (!viewerUrl) return; - - setIsVersionHistoryOpen(false); - dispatchBeamViewerOpenEvent({ - downloadUrl: version.downloadUrl, - fileExtension: getFileExtension(version.originalFilename), - fileName: version.originalFilename, - fileSize: version.size || version.conversion?.size || 0, - issueId, - viewerUrl, - }); + const openBeamVersionViewer = async (version: TBeamModelVersionRecord) => { + if (!attachment || !getBeamVersionViewerUrl(version)) return; + try { + const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl( + workspaceSlug, + projectId, + issueId, + attachment.id, + version.versionId || String(version.version) + ); + setIsVersionHistoryOpen(false); + dispatchBeamViewerOpenEvent({ + downloadUrl: version.downloadUrl, + fileExtension: getFileExtension(version.originalFilename), + fileName: version.originalFilename, + fileSize: version.size || version.conversion?.size || 0, + issueId, + viewerUrl, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Версия не открыта", + message: error instanceof Error ? error.message : "Не удалось запустить версию BIM-модели.", + }); + } }; const startVersionUpload = () => { versionUploadInputRef.current?.click(); @@ -362,7 +348,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt } }; const menuItems: TContextMenuItem[] = [ - ...(modelViewerUrl + ...(canOpenModelViewer ? [ { key: "view-model", @@ -429,7 +415,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt } let isMounted = true; - fetchBeamModelVersions(beamViewer) + attachmentService + .getBeamIssueAttachmentVersions(workspaceSlug, projectId, issueId, attachmentId) .then((history) => { if (!isMounted) return; setLiveBeamVersions(Array.isArray(history.versions) ? history.versions : []); @@ -443,7 +430,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt return () => { isMounted = false; }; - }, [beamViewer?.assetId, beamViewer?.projectId, beamViewer?.src, beamViewer?.versionId]); + }, [ + attachmentId, + attachmentService, + beamViewer?.assetId, + beamViewer?.projectId, + beamViewer?.src, + beamViewer?.versionId, + issueId, + projectId, + workspaceSlug, + ]); useEffect(() => { if (!beamViewer || !beamViewer.src) return; @@ -454,17 +451,15 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const pollStatus = async () => { try { - const status = await fetchBeamConversionStatus(beamViewer); + const status = await attachmentService.getBeamIssueAttachmentStatus( + workspaceSlug, + projectId, + issueId, + attachmentId + ); if (!isMounted) return; setBeamConversionError(null); setBeamConversionStatus(status); - const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName); - if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) { - attachmentService - .updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer) - .then(() => fetchAttachments(workspaceSlug, projectId, issueId)) - .catch((error) => console.error("Error in syncing Beam attachment metadata:", error)); - } if (status.status !== "ready" && status.status !== "failed") { timeoutId = window.setTimeout(pollStatus, 5000); } @@ -485,8 +480,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt attachmentId, attachmentService, beamViewer, - fetchAttachments, - fullFileName, issueId, projectId, storedModelViewerUrl, @@ -506,7 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt onClick={(e) => { e.preventDefault(); e.stopPropagation(); - if (modelViewerUrl) openModelViewer(); + if (canOpenModelViewer) void openModelViewer(); else setIsPreviewOpen(true); }} > @@ -586,9 +579,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt )} - {(modelViewerUrl || previewURL) && ( + {(canOpenModelViewer || previewURL) && (