Compare commits

..

10 Commits

Author SHA1 Message Date
Jedd Morgan c53a51c8ad Jrm/can create model ingestion (#486)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Add `canCreateModelIngestion` model permission check

* format

* oops
2026-01-29 14:23:44 +00:00
Jedd Morgan c1f27b78f9 feat(api)!: Add model permission checks (#485)
* Add model permission checks

* test_public

* This is the real fix

* mistake

* public api resource
2026-01-29 12:04:21 +01:00
Jedd Morgan 49d4b7d44d doc: MarkReceivedVersionInput clarification (#484)
* MarkReceivedVersionInput clarification

* Reformat
2026-01-27 19:52:30 +03:00
Jedd Morgan 7181f50dda update nullability of invitedBy (#483) 2026-01-15 20:06:13 +03:00
Mucahit Bilal GOKER 2f84214786 feat(ifc): add parentId to nested objects (#481)
* add parentId to nested objects

* rename to parentApplicationId

* implement jedd's feedback

* ruff check

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2026-01-08 09:04:12 +03:00
Jedd Morgan 0fe1af8e75 Update PostgreSQL connection string in docker-compose (#482) 2026-01-07 15:54:26 +00:00
Gergő Jedlicska 6297943fe1 gergo/version message for ingestion (#480)
* feat: use mise for docs build

* feat(modelingestion): add version message reporting
2026-01-05 11:46:31 +00:00
Gergő Jedlicska 428bbe2c3d gergo/queryIngestionFix (#479)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat: use mise for docs build

* fix: getting the ingestion query needs to use model ingestion id
2025-12-11 10:44:36 +01:00
Jedd Morgan 0ca22891bc fallback to cgal (#476) 2025-12-10 10:09:00 +00:00
Jedd Morgan fd8c2a32f9 chore(speckleifc): changed ifc status messages (#478)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* remove this function

* Changed progress messages
2025-12-09 17:27:26 +00:00
18 changed files with 271 additions and 56 deletions
+1 -4
View File
@@ -97,10 +97,7 @@ services:
STRATEGY_LOCAL: "true"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
ENABLE_MP: "false"
LOG_PRETTY: "true"
@@ -12,12 +12,23 @@ def data_object_to_speckle(
step_element: entity_instance,
children: list[Base],
current_storey: str | None = None,
parent_element: entity_instance | None = None,
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or guid)
properties = extract_properties(step_element)
# Add parent ID only if element's parent is also a DataObject (not a Collection)
# Collections are: IfcProject and IfcSpatialStructureElement types
if (
parent_element
and hasattr(parent_element, "GlobalId")
and not parent_element.is_a("IfcProject")
and not parent_element.is_a("IfcSpatialStructureElement")
):
properties["parentApplicationId"] = parent_element.GlobalId
# Add building storey information if available and not a building storey itself
if current_storey and not step_element.is_a("IfcBuildingStorey"):
properties["Building Storey"] = current_storey
+7 -1
View File
@@ -51,4 +51,10 @@ def open_ifc(file_path: str) -> file:
def create_geometry_iterator(ifc_file: file | sqlite) -> iterator:
return iterator(_create_iterator_settings(), ifc_file, multiprocessing.cpu_count())
GEOMETRY_LIBRARY = "hybrid-opencascade-cgal" # First OCC then fallback to CGAL
return iterator(
_create_iterator_settings(),
ifc_file,
multiprocessing.cpu_count(),
geometry_library=GEOMETRY_LIBRARY, # type: ignore
)
+18 -6
View File
@@ -44,9 +44,13 @@ class ImportJob:
_display_value_cache: dict[int, list[Base]] = field(default_factory=dict)
"""Maps an instance step ID to a list of instances"""
def convert_element(self, step_element: entity_instance) -> Base:
def convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
try:
return self._convert_element(step_element)
return self._convert_element(step_element, parent_element)
except SpeckleException:
raise
except Exception as ex:
@@ -54,14 +58,18 @@ class ImportJob:
f"Failed to convert {step_element.is_a()} #{step_element.id()}"
) from ex
def _convert_element(self, step_element: entity_instance) -> Base:
def _convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
# Track current storey context and store for level proxies
previous_storey_data_object = self._current_storey_data_object
if step_element.is_a("IfcBuildingStorey"):
# Convert the building storey to a DataObject for the level proxy
storey_display_value = self._display_value_cache.get(step_element.id(), [])
self._current_storey_data_object = data_object_to_speckle(
storey_display_value, step_element, []
storey_display_value, step_element, [], parent_element=None
)
children = self._convert_children(step_element)
@@ -86,7 +94,11 @@ class ImportJob:
)
else:
result = data_object_to_speckle(
display_value, step_element, children, current_storey_name
display_value,
step_element,
children,
current_storey_name,
parent_element,
)
# Associate non-spatial elements with current storey for level proxies
if self._current_storey_data_object is not None and result.applicationId:
@@ -100,7 +112,7 @@ class ImportJob:
def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [
self.convert_element(i)
self.convert_element(i, parent_element=step_element)
for i in get_children(step_element)
if self._should_convert(i)
]
+4 -3
View File
@@ -23,6 +23,7 @@ from specklepy.transports.server import ServerTransport
def open_and_convert_file(
file_path: str,
project: Project,
version_message: str,
model_ingestion_id: str,
client: SpeckleClient,
) -> Version:
@@ -57,7 +58,7 @@ def open_and_convert_file(
ModelIngestionUpdateInput(
project_id=project.id,
ingestion_id=model_ingestion_id,
progress_message="File validated, converting",
progress_message="Converting file",
progress=None,
)
)
@@ -72,7 +73,7 @@ def open_and_convert_file(
ModelIngestionUpdateInput(
project_id=project.id,
ingestion_id=model_ingestion_id,
progress_message="Conversion complete, sending",
progress_message="Uploading objects",
progress=None,
)
)
@@ -86,7 +87,7 @@ def open_and_convert_file(
project_id=project.id,
ingestion_id=model_ingestion_id,
root_object_id=root_id,
# version_message=version_message,
version_message=version_message,
)
)
@@ -26,6 +26,10 @@ class ModelIngestionResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Create"})
return super().create(input)
def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Get"})
return super().get_ingestion(project_id, model_ingestion_id)
def update_progress(self, input: ModelIngestionUpdateInput) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"})
return super().update_progress(input)
@@ -8,6 +8,10 @@ from specklepy.core.api.inputs.model_inputs import (
)
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection
from specklepy.core.api.models.current import (
ModelPermissionChecks,
PermissionCheckResult,
)
from specklepy.core.api.resources import ModelResource as CoreResource
from specklepy.logging import metrics
@@ -72,3 +76,17 @@ class ModelResource(CoreResource):
def update(self, input: UpdateModelInput) -> Model:
metrics.track(metrics.SDK, self.account, {"name": "Model Update"})
return super().update(input)
def get_permissions(self, model_id: str, project_id: str) -> ModelPermissionChecks:
metrics.track(metrics.SDK, self.account, {"name": "Model Get Permissions"})
return super().get_permissions(model_id, project_id)
def can_create_model_ingestion(
self, model_id: str, project_id: str
) -> PermissionCheckResult:
metrics.track(
metrics.SDK,
self.account,
{"name": "Model Get Permissions canCreateIngestion"},
)
return super().can_create_model_ingestion(model_id, project_id)
+1
View File
@@ -7,6 +7,7 @@ class ProjectVisibility(str, Enum):
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
UNLISTED = "UNLISTED"
"""Deprecated, use PUBLIC instead"""
WORKSPACE = "WORKSPACE"
@@ -40,6 +40,7 @@ class ModelIngestionSuccessInput(GraphQLBaseModel):
ingestion_id: str
project_id: str
root_object_id: str
version_message: str | None
class ModelIngestionFailedInput(GraphQLBaseModel):
@@ -48,18 +49,6 @@ class ModelIngestionFailedInput(GraphQLBaseModel):
error_reason: str
error_stacktrace: str | None
@staticmethod
def from_exception(
ingestion_id: str, project_id: str, exception: Exception, message: str | None
) -> "ModelIngestionFailedInput":
"""test"""
return ModelIngestionFailedInput(
ingestion_id=ingestion_id,
project_id=project_id,
error_reason=message if message else str(exception),
error_stacktrace=str(exception),
)
class ModelIngestionCancelledInput(GraphQLBaseModel):
ingestion_id: str
@@ -34,4 +34,8 @@ class MarkReceivedVersionInput(GraphQLBaseModel):
version_id: str
project_id: str
source_application: str
"""
IMPORTANT: this is meant to be the slug of the application that has done the
receiving, not to be confused with `Version.sourceApplication`
"""
message: Optional[str] = None
+7 -2
View File
@@ -105,7 +105,7 @@ class PendingStreamCollaborator(GraphQLBaseModel):
project_name: str
title: str
role: str
invited_by: LimitedUser
invited_by: LimitedUser | None = None
user: LimitedUser | None = None
token: str | None
@@ -137,6 +137,12 @@ class Version(GraphQLBaseModel):
source_application: str | None
class ModelPermissionChecks(GraphQLBaseModel):
can_update: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_create_version: "PermissionCheckResult"
class Model(GraphQLBaseModel):
author: LimitedUser | None
created_at: datetime
@@ -156,7 +162,6 @@ class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_load: "PermissionCheckResult"
can_publish: "PermissionCheckResult"
class Project(GraphQLBaseModel):
@@ -1,4 +1,4 @@
from typing import Any, Optional, Tuple
from typing import Any, Tuple
from gql import Client, gql
@@ -30,7 +30,7 @@ class ModelIngestionResource(ResourceBase):
account: Account,
basepath: str,
client: Client,
server_version: Optional[Tuple[Any, ...]],
server_version: Tuple[Any, ...] | None,
) -> None:
super().__init__(
account=account,
@@ -40,24 +40,23 @@ class ModelIngestionResource(ResourceBase):
server_version=server_version,
)
def get_ingestion(self, project_id: str, model_id: str) -> ModelIngestion:
def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
QUERY = gql(
"""
query Query($projectId: String!, $modelId: String!) {
query Query($projectId: String!, $modelIngestionId: ID!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:ingestion {
id
createdAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
data:ingestion(id: $modelIngestionId) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
@@ -68,14 +67,14 @@ class ModelIngestionResource(ResourceBase):
variables = {
"projectId": project_id,
"modelId": model_id,
"modelIngestionId": model_ingestion_id,
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ModelIngestion]]],
DataResponse[DataResponse[ModelIngestion]],
QUERY,
variables,
).data.data.data
).data.data
def create(self, input: ModelIngestionCreateInput) -> ModelIngestion:
QUERY = gql(
@@ -10,6 +10,10 @@ from specklepy.core.api.inputs.model_inputs import (
)
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection
from specklepy.core.api.models.current import (
ModelPermissionChecks,
PermissionCheckResult,
)
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
@@ -299,3 +303,71 @@ class ModelResource(ResourceBase):
return self.make_request_and_parse_response(
DataResponse[DataResponse[Model]], QUERY, variables
).data.data
def get_permissions(self, project_id: str, model_id: str) -> ModelPermissionChecks:
QUERY = gql(
"""
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
canUpdate {
authorized
code
message
}
canDelete {
authorized
code
message
}
canCreateVersion {
authorized
code
message
}
}
}
}
}
"""
)
variables = {"projectId": project_id, "modelId": model_id}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ModelPermissionChecks]]],
QUERY,
variables,
).data.data.data
def can_create_model_ingestion(
self, project_id: str, model_id: str
) -> PermissionCheckResult:
QUERY = gql(
"""
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
data:canCreateIngestion {
authorized
code
message
}
}
}
}
}
"""
)
variables = {"projectId": project_id, "modelId": model_id}
return self.make_request_and_parse_response(
DataResponse[
DataResponse[DataResponse[DataResponse[PermissionCheckResult]]]
],
QUERY,
variables,
).data.data.data.data
@@ -50,12 +50,10 @@ class TestActiveUserResourcePermissions:
assert hasattr(permissions, "can_create_model")
assert hasattr(permissions, "can_delete")
assert hasattr(permissions, "can_load")
assert hasattr(permissions, "can_publish")
assert permissions.can_create_model.authorized is True
assert permissions.can_delete.authorized is True
assert permissions.can_load.authorized is True
assert permissions.can_publish.authorized is True
def test_active_user_get_projects_with_permissions_with_filter(
self, client: SpeckleClient, test_project: Project
@@ -79,6 +79,15 @@ class TestIngestionResource:
return ingestion
def test_get_ingestion(
self, client: SpeckleClient, project: Project, ingestion: ModelIngestion
):
queried_ingestion = client.model_ingestion.get_ingestion(
project.id, ingestion.id
)
assert queried_ingestion.id == ingestion.id
assert queried_ingestion.status_data.status == ingestion.status_data.status
def test_update_progress(
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
):
@@ -198,7 +207,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id,
root_object_id=object_id,
project_id=project.id,
# version_message=None,
version_message=None,
)
res = client.model_ingestion.complete(input)
@@ -262,7 +271,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id,
root_object_id="asdfasdfasdfasfd",
project_id=project.id,
# version_message=None,
version_message=None,
)
with pytest.raises(GraphQLException):
_ = client.model_ingestion.complete(input)
@@ -1,6 +1,7 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.model_inputs import (
CreateModelInput,
DeleteModelInput,
@@ -12,11 +13,14 @@ from specklepy.core.api.inputs.project_inputs import (
)
from specklepy.core.api.models.current import (
Model,
ModelPermissionChecks,
PermissionCheckResult,
Project,
ProjectWithModels,
ResourceCollection,
)
from specklepy.logging.exceptions import GraphQLException
from tests.integration.conftest import is_internal, is_public
@pytest.mark.run()
@@ -24,7 +28,9 @@ class TestModelResource:
@pytest.fixture()
def test_project(self, client: SpeckleClient) -> Project:
project = client.project.create(
ProjectCreateInput(name="Test project", description="", visibility=None)
ProjectCreateInput(
name="Test project", description="", visibility=ProjectVisibility.PUBLIC
)
)
return project
@@ -149,3 +155,52 @@ class TestModelResource:
with pytest.raises(GraphQLException):
client.model.delete(delete_data)
def test_model_get_permissions(
self,
client: SpeckleClient,
second_client: SpeckleClient,
test_project: Project,
test_model: Model,
):
result = client.model.get_permissions(test_project.id, test_model.id)
assert isinstance(result, ModelPermissionChecks)
assert result.can_update.authorized is True
assert result.can_create_version.authorized is True
assert result.can_delete.authorized is True
guest = second_client.model.get_permissions(test_project.id, test_model.id)
assert isinstance(guest, ModelPermissionChecks)
assert guest.can_update.authorized is False
assert guest.can_create_version.authorized is False
assert guest.can_delete.authorized is False
@pytest.mark.skipif(
is_public(), reason="API only available on server versions 3.0.11 or greater"
)
def test_can_create_model_ingestion_internal_server(
self,
client: SpeckleClient,
test_project: Project,
test_model: Model,
):
result = client.model.can_create_model_ingestion(test_project.id, test_model.id)
assert isinstance(result, PermissionCheckResult)
assert result.authorized is True
@pytest.mark.skipif(
is_internal(),
reason="API only available on server versions 3.0.11 or greater",
)
def test_can_create_model_ingestion_public_server(
self,
client: SpeckleClient,
test_project: Project,
test_model: Model,
):
with pytest.raises(GraphQLException) as ex:
_ = client.model.can_create_model_ingestion(test_project.id, test_model.id)
assert "GRAPHQL_VALIDATION_FAILED" in str(ex.value)
@@ -24,6 +24,17 @@ class TestProjectResource:
)
return project
@pytest.fixture()
def test_public_project(self, client: SpeckleClient) -> Project:
project = client.project.create(
ProjectCreateInput(
name="test project123",
description="desc",
visibility=ProjectVisibility.PUBLIC,
)
)
return project
@pytest.mark.parametrize(
"name, description, visibility",
[
@@ -50,7 +61,7 @@ class TestProjectResource:
assert result.id is not None
assert result.name == name
assert result.description == (description or "")
# we've disabled creation of public projects for now, they fall back to unlisted
# we've disabled creation of unlisted projects for now, they fall back to public
if visibility == ProjectVisibility.UNLISTED:
assert result.visibility == ProjectVisibility.PUBLIC
else:
@@ -67,13 +78,32 @@ class TestProjectResource:
assert result.created_at == test_project.created_at
def test_project_get_permissions(
self, client: SpeckleClient, test_project: Project
self,
client: SpeckleClient,
second_client: SpeckleClient,
test_project: Project,
test_public_project: Project,
):
result = client.project.get_permissions(test_project.id)
result_private = client.project.get_permissions(test_project.id)
assert isinstance(result_private, ProjectPermissionChecks)
assert result_private.can_create_model.authorized is True
assert result_private.can_delete.authorized is True
assert result_private.can_load.authorized is True
result = client.project.get_permissions(test_public_project.id)
assert isinstance(result, ProjectPermissionChecks)
assert result.can_create_model.authorized is True
assert result.can_delete.authorized is True
assert result.can_load.authorized is True
guest = second_client.project.get_permissions(test_public_project.id)
assert isinstance(result, ProjectPermissionChecks)
assert guest.can_create_model.authorized is False
assert guest.can_delete.authorized is False
assert guest.can_load.authorized is False
def test_project_update(self, client: SpeckleClient, test_project: Project):
new_name = "MY new name"
+4
View File
@@ -34,6 +34,10 @@ def is_public() -> bool:
return os.getenv("IS_PUBLIC", "false").lower() == "true"
def is_internal() -> bool:
return not is_public()
def seed_user(host: str) -> Dict[str, str]:
seed = uuid.uuid4().hex
user_dict = {