ARCH - TASKER BIM: серверный CAD-шлюз и независимый просмотр из Ops
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/bim-upload/",
|
||||
IssueBimAttachmentUploadEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-upload",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-status/",
|
||||
IssueBimAttachmentStatusEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-status",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/",
|
||||
IssueBimAttachmentVersionsEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-versions",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/<str:version_id>/",
|
||||
IssueBimAttachmentVersionDetailEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-version-detail",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-viewer/",
|
||||
IssueBimAttachmentViewerEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-viewer",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(modelViewerUrl || previewURL) && (
|
||||
{(canOpenModelViewer || previewURL) && (
|
||||
<Tooltip
|
||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
tooltipContent={canOpenModelViewer ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<button
|
||||
@@ -597,7 +590,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (modelViewerUrl) openModelViewer();
|
||||
if (canOpenModelViewer) void openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -683,7 +676,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (modelViewerUrl) openModelViewer();
|
||||
if (canOpenModelViewer) void openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { API_BASE_URL } from "@plane/constants";
|
||||
// plane types
|
||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
||||
import type {
|
||||
TFileMetaDataLite,
|
||||
TIssueAttachment,
|
||||
TIssueAttachmentUploadResponse,
|
||||
TIssueServiceType,
|
||||
@@ -17,17 +16,11 @@ import type {
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import {
|
||||
deleteBeamModelAsset,
|
||||
deleteBeamModelVersion,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamModelMimeType,
|
||||
getBeamViewerAttachment,
|
||||
getBeamViewerVersionNumber,
|
||||
isBeamModelFile,
|
||||
mergeBeamModelVersionRecords,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
type TBeamConversionStatus,
|
||||
type TBeamVersionHistoryResponse,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
@@ -49,6 +42,15 @@ export class IssueAttachmentService extends APIService {
|
||||
this.serviceType = serviceType;
|
||||
}
|
||||
|
||||
private attachmentBasePath(workspaceSlug: string, projectId: string, issueId: string): string {
|
||||
return `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments`;
|
||||
}
|
||||
|
||||
private toBeamRequestError(error: unknown, fallback: string): Error {
|
||||
const responseData = (error as { response?: { data?: { message?: string; error?: string } } })?.response?.data;
|
||||
return new Error(responseData?.message || responseData?.error || fallback);
|
||||
}
|
||||
|
||||
private async updateIssueAttachmentUploadStatus(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -64,43 +66,6 @@ export class IssueAttachmentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createBeamIssueAttachmentReference(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
payload
|
||||
)
|
||||
.then((response) => {
|
||||
const createResponse: TIssueAttachmentCreateResponse = response?.data;
|
||||
return createResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
async updateBeamIssueAttachmentReference(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
attributes: Record<string, unknown> = {}
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.patch(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
||||
{ ...attributes, beamViewer }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBeamIssueAttachmentVersion(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -108,60 +73,26 @@ export class IssueAttachmentService extends APIService {
|
||||
attachment: TIssueAttachment,
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
|
||||
uploadedBy?: string
|
||||
_uploadedBy?: string
|
||||
): Promise<TIssueAttachment> {
|
||||
const previousBeamViewer = getBeamViewerAttachment(attachment);
|
||||
if (!previousBeamViewer) {
|
||||
if (!getBeamViewerAttachment(attachment)) {
|
||||
throw new Error("Для этого вложения нет BIM-метаданных.");
|
||||
}
|
||||
if (!isBeamModelFile(file.name)) {
|
||||
throw new Error("Формат модели не поддерживается BIM Viewer.");
|
||||
}
|
||||
|
||||
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
|
||||
const currentVersion = Math.max(
|
||||
getBeamViewerVersionNumber(previousBeamViewer, attachment),
|
||||
...previousVersions.map((version) => version.version)
|
||||
);
|
||||
const nextVersion = currentVersion + 1;
|
||||
const assetId = previousBeamViewer.assetId || attachment.id;
|
||||
const nextBeamViewer = await uploadBeamModelFile(file, {
|
||||
assetId,
|
||||
issueId,
|
||||
onUploadProgress: uploadProgressHandler,
|
||||
projectId,
|
||||
uploadedBy,
|
||||
version: nextVersion,
|
||||
workspaceSlug,
|
||||
});
|
||||
const nextVersions = mergeBeamModelVersionRecords(
|
||||
{ ...previousBeamViewer, assetId, versions: previousVersions },
|
||||
nextBeamViewer,
|
||||
attachment
|
||||
);
|
||||
|
||||
return this.updateBeamIssueAttachmentReference(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment.id,
|
||||
{
|
||||
...nextBeamViewer,
|
||||
assetId,
|
||||
projectId: nextBeamViewer.projectId || previousBeamViewer.projectId,
|
||||
previewAvailable: nextBeamViewer.previewAvailable,
|
||||
uploadedBy,
|
||||
version: nextVersion,
|
||||
versions: nextVersions,
|
||||
viewerUrl: nextBeamViewer.viewerUrl ?? null,
|
||||
},
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: getBeamModelMimeType(file),
|
||||
version: nextVersion,
|
||||
}
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/`,
|
||||
formData,
|
||||
{ onUploadProgress: uploadProgressHandler }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось загрузить новую версию BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBeamIssueAttachmentVersion(
|
||||
@@ -171,36 +102,69 @@ export class IssueAttachmentService extends APIService {
|
||||
attachment: TIssueAttachment,
|
||||
versionToDelete: TBeamModelVersionRecord
|
||||
): Promise<TIssueAttachment> {
|
||||
const beamViewer = getBeamViewerAttachment(attachment);
|
||||
if (!beamViewer) {
|
||||
if (!getBeamViewerAttachment(attachment)) {
|
||||
throw new Error("Для этого вложения нет BIM-метаданных.");
|
||||
}
|
||||
const versionKey = encodeURIComponent(versionToDelete.versionId || String(versionToDelete.version));
|
||||
return this.delete(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/${versionKey}/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось удалить версию BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
const currentVersion = getBeamViewerVersionNumber(beamViewer, attachment);
|
||||
const isCurrentVersion = versionToDelete.versionId
|
||||
? beamViewer.versionId === versionToDelete.versionId
|
||||
: currentVersion === versionToDelete.version;
|
||||
if (isCurrentVersion) {
|
||||
throw new Error("Текущую версию нельзя удалить. Сначала переключите модель на другую версию.");
|
||||
}
|
||||
async getBeamIssueAttachmentStatus(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string
|
||||
): Promise<TBeamConversionStatus> {
|
||||
return this.get(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-status/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось получить статус BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
const versions = getBeamModelVersionRecords(beamViewer, attachment);
|
||||
const nextVersions = versions.filter((version) =>
|
||||
versionToDelete.versionId
|
||||
? version.versionId !== versionToDelete.versionId
|
||||
: version.version !== versionToDelete.version
|
||||
);
|
||||
async getBeamIssueAttachmentVersions(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string
|
||||
): Promise<TBeamVersionHistoryResponse> {
|
||||
return this.get(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-versions/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось получить историю BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
if (nextVersions.length === versions.length) {
|
||||
throw new Error("Версия не найдена.");
|
||||
}
|
||||
|
||||
await deleteBeamModelVersion(versionToDelete);
|
||||
|
||||
return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
|
||||
...beamViewer,
|
||||
versions: nextVersions,
|
||||
});
|
||||
async getBeamIssueAttachmentViewerUrl(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
versionId?: string
|
||||
): Promise<string> {
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-viewer/`,
|
||||
versionId ? { versionId } : {}
|
||||
)
|
||||
.then((response) => {
|
||||
const viewerUrl = response?.data?.viewerUrl;
|
||||
if (!viewerUrl) throw new Error("Ops не вернул ссылку запуска BIM Viewer.");
|
||||
return viewerUrl as string;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && !("response" in error)) throw error;
|
||||
throw this.toBeamRequestError(error, "Не удалось открыть BIM-модель.");
|
||||
});
|
||||
}
|
||||
|
||||
async uploadIssueAttachment(
|
||||
@@ -211,19 +175,20 @@ export class IssueAttachmentService extends APIService {
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TIssueAttachment> {
|
||||
if (isBeamModelFile(file.name)) {
|
||||
const beamViewer = await uploadBeamModelFile(file, {
|
||||
issueId,
|
||||
onUploadProgress: uploadProgressHandler,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
|
||||
beamViewer,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: getBeamModelMimeType(file),
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/bim-upload/`,
|
||||
formData,
|
||||
{ onUploadProgress: uploadProgressHandler }
|
||||
)
|
||||
.then((response) => {
|
||||
const createResponse: TIssueAttachmentCreateResponse = response?.data;
|
||||
return createResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось загрузить модель через Ops.");
|
||||
});
|
||||
}
|
||||
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
@@ -278,15 +243,8 @@ export class IssueAttachmentService extends APIService {
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
assetId: string,
|
||||
attachment?: TIssueAttachment
|
||||
_attachment?: TIssueAttachment
|
||||
): Promise<TIssueAttachment> {
|
||||
const beamViewer = getBeamViewerAttachment(attachment);
|
||||
if (beamViewer) {
|
||||
await deleteBeamModelAsset(beamViewer).catch((error) => {
|
||||
console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.delete(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { TIssueAttachment } from "@plane/types";
|
||||
import {
|
||||
DEFAULT_BEAM_VIEWER_BASE_URL,
|
||||
@@ -43,12 +42,13 @@ export type TBeamModelVersionRecord = {
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
assetId?: string;
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
backend: "beam-viewer-local" | "beam-viewer-ops" | "beam-viewer";
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
projectId?: string;
|
||||
registryOwnerId?: string;
|
||||
sha256?: string;
|
||||
src: string;
|
||||
type: string;
|
||||
@@ -115,30 +115,13 @@ const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
const normalizeBaseUrl = (url: string | undefined): string =>
|
||||
(url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
const getUploadGroupId = (parts: Array<string | undefined>): string => {
|
||||
const value = parts
|
||||
.filter(Boolean)
|
||||
.join("_")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return value || "tasker";
|
||||
};
|
||||
|
||||
const toBeamRelativeUploadSrc = (src: string): string => {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.pathname.replace(/^\/+/, "");
|
||||
} catch (_error) {
|
||||
return src.replace(/^\/+/, "");
|
||||
}
|
||||
};
|
||||
|
||||
const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => {
|
||||
if (!src) return undefined;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src);
|
||||
} catch (_error) {
|
||||
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`);
|
||||
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamViewerBaseUrl()}/`);
|
||||
}
|
||||
if (cacheKey) url.searchParams.set("v", cacheKey);
|
||||
return url.toString();
|
||||
@@ -146,9 +129,6 @@ const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string |
|
||||
|
||||
export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||
|
||||
export const getBeamApiBaseUrl = (): string =>
|
||||
normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||
|
||||
export const getBeamModelTypeFromName = (name: string | undefined): string | null => {
|
||||
return getBeamModelTypeFromFileName(name);
|
||||
};
|
||||
@@ -317,188 +297,3 @@ export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): strin
|
||||
type: artifactType,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
|
||||
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
|
||||
const response = await fetch(statusUrl.toString(), { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as TBeamConversionStatus;
|
||||
return {
|
||||
...payload,
|
||||
artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt),
|
||||
metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBeamModelVersions = async (
|
||||
beamViewer: TBeamViewerAttachment
|
||||
): Promise<TBeamVersionHistoryResponse> => {
|
||||
const versionsUrl = new URL("/api/uploads/versions", `${getBeamApiBaseUrl()}/`);
|
||||
if (beamViewer.projectId && beamViewer.assetId) {
|
||||
versionsUrl.searchParams.set("projectId", beamViewer.projectId);
|
||||
versionsUrl.searchParams.set("assetId", beamViewer.assetId);
|
||||
} else {
|
||||
versionsUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
}
|
||||
|
||||
const response = await fetch(versionsUrl.toString(), { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`BIM version history failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as TBeamVersionHistoryResponse;
|
||||
};
|
||||
|
||||
export const uploadBeamModelFile = (
|
||||
file: File,
|
||||
options: {
|
||||
assetId?: string;
|
||||
issueId?: string;
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||
projectId?: string;
|
||||
uploadedBy?: string;
|
||||
version?: number;
|
||||
workspaceSlug?: string;
|
||||
} = {}
|
||||
): Promise<TBeamViewerAttachment> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const type = getBeamModelTypeFromName(file.name);
|
||||
if (!type) {
|
||||
reject(new Error("Формат модели не поддерживается BIM Viewer"));
|
||||
return;
|
||||
}
|
||||
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||
|
||||
const apiBaseUrl = getBeamApiBaseUrl();
|
||||
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
|
||||
uploadUrl.searchParams.set("filename", file.name);
|
||||
uploadUrl.searchParams.set(
|
||||
"projectId",
|
||||
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
|
||||
);
|
||||
if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
|
||||
if (options.version) uploadUrl.searchParams.set("version", String(options.version));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", uploadUrl.toString());
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable || !options.onUploadProgress) return;
|
||||
options.onUploadProgress({
|
||||
loaded: event.loaded,
|
||||
progress: event.loaded / event.total,
|
||||
total: event.total,
|
||||
} as AxiosProgressEvent);
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText) as {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerAttachment["conversion"];
|
||||
originalFilename?: string;
|
||||
projectId?: string;
|
||||
sha256?: string;
|
||||
src?: string;
|
||||
uploadedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
if (!response.src) {
|
||||
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
||||
const baseAttachment: TBeamViewerAttachment = {
|
||||
assetId: response.assetId ?? options.assetId,
|
||||
backend: "beam-viewer-local",
|
||||
downloadUrl: downloadUrl.toString(),
|
||||
originalFilename: response.originalFilename || file.name,
|
||||
previewAvailable: !!directViewerType,
|
||||
projectId: response.projectId,
|
||||
sha256: response.sha256,
|
||||
src: downloadUrl.toString(),
|
||||
type,
|
||||
uploadedBy: options.uploadedBy,
|
||||
uploadedAt: response.uploadedAt || new Date().toISOString(),
|
||||
version: response.version ?? options.version ?? 1,
|
||||
versionId: response.versionId,
|
||||
};
|
||||
|
||||
if (!directViewerType) {
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
conversion: {
|
||||
...response.conversion,
|
||||
componentTreeRequired: true,
|
||||
message:
|
||||
response.conversion?.message ??
|
||||
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
|
||||
sourceFormat: type,
|
||||
status: response.conversion?.status ?? "conversion_required",
|
||||
targetFormat: response.conversion?.targetFormat ?? "xkt",
|
||||
},
|
||||
};
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: file.name,
|
||||
settingsSrc: downloadUrl.toString(),
|
||||
src: downloadUrl.toString(),
|
||||
type: directViewerType,
|
||||
}),
|
||||
};
|
||||
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
} catch (_error) {
|
||||
reject(new Error("BIM Viewer вернул некорректный ответ"));
|
||||
}
|
||||
});
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const deleteBeamStorageResource = async (
|
||||
endpoint: "/api/uploads/asset" | "/api/uploads/version",
|
||||
payload: unknown
|
||||
): Promise<void> => {
|
||||
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
|
||||
|
||||
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/version", version);
|
||||
|
||||
Reference in New Issue
Block a user