ARCH - TASKER BIM: серверный CAD-шлюз и независимый просмотр из Ops

This commit is contained in:
DCCONSTRUCTIONS
2026-07-30 07:35:23 +03:00
parent 24481ea432
commit ad04910209
10 changed files with 1252 additions and 499 deletions
@@ -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,
)