generated from coulomb/repo-seed
Scoped CMIS workspace folders with create, list, parent, path lookup, delete, and delete-tree behavior
This commit is contained in:
123
docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md
Normal file
123
docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# CMIS WP-0014 OpenCMIS Evidence - 2026-05-08T13:44:32Z
|
||||
|
||||
## Scope
|
||||
|
||||
This note records the WP-0014 implementation evidence for raising CMIS 1.1
|
||||
Browser Binding object/content maturity in `kontextual-engine`.
|
||||
|
||||
CMIS remains an adapter over native engine services. The work in this pass
|
||||
focused on compatibility features that map naturally to existing assets,
|
||||
representations, metadata, policy gates, and projection folders.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Added profile-scoped adapter-managed workspace folders for CMIS-created
|
||||
folders.
|
||||
- Added Browser Binding `createFolder`, `createDocument`, `deleteTree`, and
|
||||
singular `parent` selector handling.
|
||||
- Added form-url-encoded and multipart Browser Binding action parsing without
|
||||
introducing a new multipart dependency.
|
||||
- Added document/folder path projection and Browser Binding `getObjectByPath`
|
||||
support through the root selector.
|
||||
- Tightened folder lifecycle so deleted workspace folders stop resolving as
|
||||
phantom virtual folders.
|
||||
- Returned full folder projections from `getObjectParents`, including
|
||||
`cmis:path` for OpenCMIS `getPaths()`.
|
||||
- Declared emitted document/folder custom properties in CMIS type metadata.
|
||||
- Included CMIS document version/read-only properties expected by common CMIS
|
||||
clients while keeping versioning operations unsupported.
|
||||
- Prevented CMIS-authored documents from appearing multifiled when the
|
||||
repository advertises `capabilityMultifiling=false`.
|
||||
- Corrected document allowable actions so non-folder objects do not advertise
|
||||
`canGetFolderParent`.
|
||||
|
||||
## Local Verification
|
||||
|
||||
Focused CMIS tests:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/cmis --perf-history-disable
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- `47 passed`
|
||||
|
||||
## OpenCMIS Run
|
||||
|
||||
Command shape:
|
||||
|
||||
```bash
|
||||
cd /home/worsch/guide-board
|
||||
source /home/worsch/open-cmis-tck/.local/toolchains/env.sh
|
||||
PYTHONPATH=src python3 -m guide_board \
|
||||
--extension-dir ../open-cmis-tck \
|
||||
run \
|
||||
--target /tmp/kontextual-cmis-compat-8010.json \
|
||||
--assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
|
||||
--output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- Run ID: `run-20260508T134448Z`
|
||||
- Run directory: `/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z`
|
||||
- Harness status: `infrastructure_error`
|
||||
- Scorecard: `/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z/reports/cmis-maturity-scorecard.md`
|
||||
|
||||
The scorecard still classifies the two mapped groups as infrastructure-blocked,
|
||||
but the raw OpenCMIS output shows meaningful progress inside the test groups.
|
||||
|
||||
## Evidence Progression
|
||||
|
||||
Earlier WP-0014 runs stopped at foundational Browser Binding gaps:
|
||||
|
||||
- Unsupported `parent` selector.
|
||||
- Folder custom properties declared by type metadata but missing from root
|
||||
folder objects.
|
||||
- Multipart `createDocument` action bodies not parsed because multipart
|
||||
boundaries were lowercased.
|
||||
- `deleteTree` unsupported during OpenCMIS cleanup.
|
||||
- CMIS-created folders still resolving after deletion as generic virtual
|
||||
folders.
|
||||
- Document projections exposing undeclared custom fields such as
|
||||
`kontextual:assetId`.
|
||||
- OpenCMIS `getPaths()` failing because parent folder objects lacked
|
||||
`cmis:path`.
|
||||
|
||||
The final run reaches deeper object/content behaviors:
|
||||
|
||||
- Repository info checks pass.
|
||||
- Root folder test passes.
|
||||
- Type definition enumeration runs.
|
||||
- Document creation proceeds far enough to exercise update, move, delete-tree,
|
||||
operation-context, and async object lookup tests.
|
||||
- Delete-tree tests create many documents and report MIME warnings rather than
|
||||
basic object-creation failures.
|
||||
|
||||
## Current Frontier
|
||||
|
||||
Remaining natural CMIS maturity items:
|
||||
|
||||
- Browser Binding action aliases: OpenCMIS sends `cmisaction=update` and
|
||||
`cmisaction=move`; we currently expose `updateProperties` and do not support
|
||||
move.
|
||||
- Operation-context fidelity: `getObject()` and `getChildren()` currently
|
||||
return more properties, ACLs, allowable actions, and path segments than
|
||||
requested.
|
||||
- MIME normalization: OpenCMIS expects `text/plain`; some created streams are
|
||||
reported as `text/plain; charset=utf-8`.
|
||||
- Async `getObjectByPath`: OpenCMIS async child/folder checks still hit a
|
||||
`Not Found` path lookup case.
|
||||
- Copy/move and secondary type mutation remain unsupported unless a later
|
||||
workplan admits them.
|
||||
|
||||
## Interpretation
|
||||
|
||||
The adapter foundation is sounder after this pass. The failures are no longer
|
||||
basic session, repository, folder-creatable, multipart action parsing, or parent
|
||||
path hydration issues. They are now mostly CMIS client polish around action
|
||||
aliases, response filtering, and optional ECM behaviors.
|
||||
|
||||
WP-0014 should remain active for follow-up maturity work, but the core
|
||||
folder/object-content compatibility foundation is implemented.
|
||||
@@ -7,10 +7,13 @@ requests into service/runtime contracts and must not own domain behavior.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository, InMemoryBlobStorage
|
||||
from kontextual_engine.core import (
|
||||
@@ -23,6 +26,7 @@ from kontextual_engine.core import (
|
||||
CMISAccessPoint,
|
||||
CMISAccessProfile,
|
||||
CMISAction,
|
||||
CMISBaseType,
|
||||
CMISDomainMapper,
|
||||
ContextEntity,
|
||||
ContextEntityType,
|
||||
@@ -188,11 +192,25 @@ AGENT_OPERATION_CATALOG: tuple[dict[str, Any], ...] = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CMISWorkspaceFolder:
|
||||
access_point_id: str
|
||||
object_id: str
|
||||
path: str
|
||||
name: str
|
||||
parent_id: str
|
||||
created_by: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
lifecycle: str = LifecycleState.ACTIVE.value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceRuntime:
|
||||
repository: AssetRegistryRepository = field(default_factory=InMemoryAssetRegistryRepository)
|
||||
blob_storage: BlobStorage = field(default_factory=InMemoryBlobStorage)
|
||||
policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway)
|
||||
cmis_workspace_folders: dict[str, dict[str, CMISWorkspaceFolder]] = field(default_factory=dict)
|
||||
api_version: str = API_VERSION
|
||||
service_name: str = "kontextual-engine"
|
||||
started_at: str = field(default_factory=lambda: utc_now().isoformat())
|
||||
@@ -427,9 +445,23 @@ class ServiceRuntime:
|
||||
return self.cmis_browser_root_object(access_point_id)
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
return cmis_browser_object(self._cmis_mapper(access_point_id).folder_projection(folder_path))
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return cmis_browser_object(self._cmis_folder_projection(access_point_id, folder_path))
|
||||
return cmis_browser_object(self.cmis_object(access_point_id, object_id, context))
|
||||
|
||||
def cmis_browser_object_by_path(
|
||||
self,
|
||||
access_point_id: str,
|
||||
path: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
return cmis_browser_object(self.cmis_object_by_path(access_point_id, path, context))
|
||||
|
||||
def cmis_browser_children(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -456,6 +488,20 @@ class ServiceRuntime:
|
||||
) -> list[dict[str, Any]]:
|
||||
return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context))
|
||||
|
||||
def cmis_browser_parent(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
parents = self.cmis_object_parents(access_point_id, object_id, context).get("parents", [])
|
||||
if not parents:
|
||||
raise NotFoundError(
|
||||
"CMIS folder parent not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return cmis_browser_object(parents[0])
|
||||
|
||||
def cmis_browser_query(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -510,6 +556,14 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getObject")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return self._cmis_folder_projection(access_point_id, folder_path)
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
projection = mapper.map_asset(
|
||||
@@ -530,6 +584,53 @@ class ServiceRuntime:
|
||||
)
|
||||
return projection.to_dict()
|
||||
|
||||
def cmis_object_by_path(
|
||||
self,
|
||||
access_point_id: str,
|
||||
path: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
normalized = _normalize_cmis_path(path)
|
||||
if normalized == "/":
|
||||
return cmis_browser_root_folder(mapper.access_point)
|
||||
workspace_folder = self._cmis_workspace_folder_map(access_point_id).get(normalized)
|
||||
if workspace_folder is not None:
|
||||
return self._cmis_workspace_folder_projection(mapper, workspace_folder)
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if normalized not in mapper.asset_paths(asset):
|
||||
continue
|
||||
projection = mapper.map_asset(
|
||||
asset,
|
||||
context,
|
||||
representations=self.repository.list_representations(asset_id=asset.id),
|
||||
versions=self.repository.list_versions(asset.id),
|
||||
relationship_ids=[
|
||||
f"cmis:relationship:{relationship.relationship_id}"
|
||||
for relationship in self.repository.list_relationships(source_id=asset.id)
|
||||
if self._cmis_relationship_visible(mapper, relationship, context)
|
||||
],
|
||||
metadata_records=self.repository.list_metadata_records(asset.id),
|
||||
)
|
||||
if projection is not None:
|
||||
return projection.to_dict()
|
||||
if any(
|
||||
folder.path != normalized and _path_contains(normalized, folder.path)
|
||||
for folder in self._cmis_workspace_folder_map(access_point_id).values()
|
||||
):
|
||||
return mapper.folder_projection(normalized)
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if any(asset_path != normalized and _path_contains(normalized, asset_path) for asset_path in mapper.asset_paths(asset)):
|
||||
return mapper.folder_projection(normalized)
|
||||
raise NotFoundError(
|
||||
"CMIS object path not found",
|
||||
details={"path": normalized, "access_point_id": access_point_id},
|
||||
)
|
||||
|
||||
def cmis_content_stream(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -598,6 +699,18 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT_PARENTS, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getObjectParents")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
if folder_path == "/":
|
||||
return {"object_id": object_id, "parents": [], "count": 0}
|
||||
parent_path = _path_parent(folder_path)
|
||||
parent = self._cmis_folder_projection(access_point_id, parent_path)
|
||||
return {"object_id": object_id, "parents": [parent], "count": 1}
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
@@ -605,9 +718,73 @@ class ServiceRuntime:
|
||||
"CMIS object not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
parents = list(mapper.parent_folders_for_asset(asset))
|
||||
explicit_cmis_path = asset.metadata.get("cmis_path")
|
||||
parent_paths = (
|
||||
[_path_parent(str(explicit_cmis_path))]
|
||||
if explicit_cmis_path
|
||||
else [parent["path"] for parent in mapper.parent_folders_for_asset(asset)]
|
||||
)
|
||||
parents = [self._cmis_folder_projection(access_point_id, path) for path in dict.fromkeys(parent_paths)]
|
||||
return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)}
|
||||
|
||||
def cmis_create_folder(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.CREATE_FOLDER, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "createFolder")
|
||||
properties = dict(payload.get("properties", {}))
|
||||
name = str(payload.get("name") or properties.get("cmis:name") or "").strip()
|
||||
if not name:
|
||||
raise ValidationError("CMIS folder name is required", details={"operation": "createFolder"})
|
||||
type_id = properties.get("cmis:objectTypeId", payload.get("type_id", CMISBaseType.FOLDER.value))
|
||||
if type_id not in {CMISBaseType.FOLDER.value, "kontextual:folder"}:
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS folder type",
|
||||
details={"operation": "createFolder", "type_id": type_id, "supported": [CMISBaseType.FOLDER.value]},
|
||||
)
|
||||
parent_id = parent_folder_id or payload.get("folder_id") or payload.get("folderId") or "cmis-root"
|
||||
parent_path = _cmis_folder_path(parent_id) or "/"
|
||||
folder_path = _normalize_cmis_path(f"{parent_path}/{name}")
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path in folders:
|
||||
raise ValidationError(
|
||||
"CMIS folder already exists",
|
||||
details={"operation": "createFolder", "path": folder_path},
|
||||
)
|
||||
parent_object_id = "cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path)
|
||||
now = utc_now().isoformat()
|
||||
folder = CMISWorkspaceFolder(
|
||||
access_point_id=access_point_id,
|
||||
object_id=mapper.folder_object_id(folder_path),
|
||||
path=folder_path,
|
||||
name=_path_name(folder_path),
|
||||
parent_id=parent_object_id,
|
||||
created_by=context.actor.id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
folders[folder.path] = folder
|
||||
return self._cmis_workspace_folder_projection(mapper, folder)
|
||||
|
||||
def cmis_browser_create_folder(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return cmis_browser_object(
|
||||
self.cmis_create_folder(access_point_id, payload, context, parent_folder_id=parent_folder_id)
|
||||
)
|
||||
|
||||
def cmis_create_document(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -618,6 +795,10 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.CREATE_DOCUMENT, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "createDocument")
|
||||
properties = dict(payload.get("properties", {}))
|
||||
name = payload.get("name") or properties.get("cmis:name")
|
||||
if not name:
|
||||
raise ValidationError("CMIS document name is required", details={"operation": "createDocument"})
|
||||
classification = Classification.from_dict(
|
||||
{
|
||||
"asset_type": payload.get("asset_type", "document"),
|
||||
@@ -640,7 +821,7 @@ class ServiceRuntime:
|
||||
)
|
||||
representations.append(representation)
|
||||
result = self.asset_service().create_asset(
|
||||
payload["name"],
|
||||
str(name),
|
||||
classification,
|
||||
context,
|
||||
asset_id=asset_id,
|
||||
@@ -648,8 +829,34 @@ class ServiceRuntime:
|
||||
metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", [])],
|
||||
idempotency_key=payload.get("idempotency_key"),
|
||||
)
|
||||
folder_id = payload.get("folder_id") or payload.get("folderId")
|
||||
if folder_id:
|
||||
folder_path = _cmis_folder_path(folder_id) or "/"
|
||||
asset_path = _normalize_cmis_path(f"{folder_path}/{name}")
|
||||
metadata = {
|
||||
**result.asset.metadata,
|
||||
"cmis_path": asset_path,
|
||||
"cmis_parent_folder_id": "cmis-root"
|
||||
if folder_path == "/"
|
||||
else mapper.folder_object_id(folder_path),
|
||||
"file_name": str(name),
|
||||
}
|
||||
self.repository.save_asset(replace(result.asset, metadata=metadata))
|
||||
return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context)
|
||||
|
||||
def cmis_browser_create_document(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = dict(payload)
|
||||
if parent_folder_id and "folder_id" not in payload and "folderId" not in payload:
|
||||
payload["folder_id"] = parent_folder_id
|
||||
return cmis_browser_object(self.cmis_create_document(access_point_id, payload, context))
|
||||
|
||||
def cmis_update_properties(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -729,6 +936,29 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.DELETE_OBJECT, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "deleteObject")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if folder_path == "/":
|
||||
raise ValidationError("CMIS root folder cannot be deleted", details={"operation": "deleteObject"})
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path not in folders:
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
children = self._cmis_children_for_folder(mapper, context, folder_path=folder_path)
|
||||
if children:
|
||||
raise ValidationError(
|
||||
"CMIS folder is not empty",
|
||||
details={"operation": "deleteObject", "object_id": object_id, "child_count": len(children)},
|
||||
)
|
||||
del folders[folder_path]
|
||||
return {
|
||||
"object_id": object_id,
|
||||
"deleted": True,
|
||||
"lifecycle": LifecycleState.DELETE_REQUESTED.value,
|
||||
"profile": access_point_id,
|
||||
}
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
result = self.asset_service().request_delete(
|
||||
asset_id,
|
||||
@@ -744,6 +974,56 @@ class ServiceRuntime:
|
||||
"policy_decision": result.policy_decision.to_dict(),
|
||||
}
|
||||
|
||||
def cmis_delete_tree(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.DELETE_TREE, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "deleteTree")
|
||||
folder_path = _cmis_folder_path(object_id)
|
||||
if folder_path in (None, "/"):
|
||||
raise ValidationError("CMIS root folder cannot be deleteTree target", details={"operation": "deleteTree"})
|
||||
|
||||
failed_to_delete: list[str] = []
|
||||
deleted_assets = 0
|
||||
for asset in list(self.repository.list_assets()):
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if not any(_path_contains(folder_path, path) for path in mapper.asset_paths(asset)):
|
||||
continue
|
||||
try:
|
||||
self.asset_service().request_delete(
|
||||
asset.id,
|
||||
context,
|
||||
expected_current_version_id=payload.get("expected_current_version_id"),
|
||||
)
|
||||
deleted_assets += 1
|
||||
except Exception:
|
||||
failed_to_delete.append(mapper.asset_object_id(asset.id))
|
||||
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
folder_paths = [
|
||||
path
|
||||
for path in folders
|
||||
if path == folder_path or _path_contains(folder_path, path)
|
||||
]
|
||||
for path in sorted(folder_paths, key=lambda item: item.count("/"), reverse=True):
|
||||
del folders[path]
|
||||
|
||||
return {
|
||||
"failedToDelete": failed_to_delete,
|
||||
"failed_to_delete": failed_to_delete,
|
||||
"deleted": len(folder_paths) + deleted_assets,
|
||||
"deleted_folders": len(folder_paths),
|
||||
"deleted_assets": deleted_assets,
|
||||
"profile": access_point_id,
|
||||
}
|
||||
|
||||
def cmis_query(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -878,6 +1158,8 @@ class ServiceRuntime:
|
||||
for asset in self.repository.list_assets()
|
||||
if mapper.access_point.exposes_asset(asset, context)
|
||||
]
|
||||
access_point_id = mapper.access_point.access_point_id
|
||||
workspace_folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path in (None, "/"):
|
||||
child_folder_paths = set()
|
||||
for asset in assets:
|
||||
@@ -885,10 +1167,23 @@ class ServiceRuntime:
|
||||
first = path.strip("/").split("/")[0]
|
||||
if first:
|
||||
child_folder_paths.add("/" + first)
|
||||
return [mapper.folder_projection(path) for path in sorted(child_folder_paths)]
|
||||
workspace_children = [
|
||||
self._cmis_workspace_folder_projection(mapper, folder)
|
||||
for folder in workspace_folders.values()
|
||||
if _path_parent(folder.path) == "/"
|
||||
]
|
||||
projection_children = [
|
||||
mapper.folder_projection(path)
|
||||
for path in sorted(child_folder_paths)
|
||||
if path not in workspace_folders
|
||||
]
|
||||
return sorted(workspace_children + projection_children, key=lambda item: item["path"])
|
||||
children: list[dict[str, Any]] = []
|
||||
folder_path = _normalize_cmis_path(folder_path)
|
||||
child_folder_paths: set[str] = set()
|
||||
for folder in workspace_folders.values():
|
||||
if _path_parent(folder.path) == folder_path:
|
||||
children.append(self._cmis_workspace_folder_projection(mapper, folder))
|
||||
for asset in assets:
|
||||
for path in mapper.asset_paths(asset):
|
||||
parent = _path_parent(path)
|
||||
@@ -909,7 +1204,90 @@ class ServiceRuntime:
|
||||
children.append(projection.to_dict())
|
||||
elif _path_parent(parent) == folder_path:
|
||||
child_folder_paths.add(parent)
|
||||
return [mapper.folder_projection(path) for path in sorted(child_folder_paths)] + children
|
||||
projection_children = [
|
||||
mapper.folder_projection(path)
|
||||
for path in sorted(child_folder_paths)
|
||||
if path not in workspace_folders
|
||||
]
|
||||
return projection_children + children
|
||||
|
||||
def _cmis_workspace_folder_map(self, access_point_id: str) -> dict[str, CMISWorkspaceFolder]:
|
||||
return self.cmis_workspace_folders.setdefault(access_point_id, {})
|
||||
|
||||
def _cmis_folder_projection(self, access_point_id: str, folder_path: str) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
normalized = _normalize_cmis_path(folder_path)
|
||||
if normalized == "/":
|
||||
return cmis_browser_root_folder(mapper.access_point)
|
||||
folder = self._cmis_workspace_folder_map(access_point_id).get(normalized)
|
||||
if folder is not None:
|
||||
return self._cmis_workspace_folder_projection(mapper, folder)
|
||||
return mapper.folder_projection(normalized)
|
||||
|
||||
def _cmis_folder_exists(
|
||||
self,
|
||||
mapper: CMISDomainMapper,
|
||||
context: OperationContext,
|
||||
folder_path: str,
|
||||
) -> bool:
|
||||
normalized = _normalize_cmis_path(folder_path)
|
||||
if normalized == "/":
|
||||
return True
|
||||
workspace_folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id)
|
||||
if normalized in workspace_folders:
|
||||
return True
|
||||
if any(_path_contains(normalized, folder.path) for folder in workspace_folders.values()):
|
||||
return True
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if any(_path_contains(normalized, path) for path in mapper.asset_paths(asset)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmis_workspace_folder_projection(
|
||||
self,
|
||||
mapper: CMISDomainMapper,
|
||||
folder: CMISWorkspaceFolder,
|
||||
) -> dict[str, Any]:
|
||||
projection = mapper.folder_projection(folder.path)
|
||||
projection["object_id"] = folder.object_id
|
||||
projection["name"] = folder.name
|
||||
projection["path"] = folder.path
|
||||
projection["properties"].update(
|
||||
{
|
||||
"cmis:objectId": folder.object_id,
|
||||
"cmis:name": folder.name,
|
||||
"cmis:objectTypeId": CMISBaseType.FOLDER.value,
|
||||
"cmis:createdBy": folder.created_by,
|
||||
"cmis:lastModifiedBy": folder.created_by,
|
||||
"cmis:creationDate": folder.created_at,
|
||||
"cmis:lastModificationDate": folder.updated_at,
|
||||
"cmis:changeToken": f"folder:{folder.updated_at}",
|
||||
"cmis:parentId": folder.parent_id,
|
||||
"cmis:description": "Adapter-managed CMIS workspace folder",
|
||||
"kontextual:workspaceFolder": True,
|
||||
}
|
||||
)
|
||||
actions = set(projection.get("allowable_actions", []))
|
||||
actions.update(
|
||||
{
|
||||
CMISAction.GET_OBJECT.value,
|
||||
CMISAction.GET_CHILDREN.value,
|
||||
CMISAction.GET_OBJECT_PARENTS.value,
|
||||
}
|
||||
)
|
||||
if mapper.access_point.profile.allow_mutations:
|
||||
actions.update(
|
||||
{
|
||||
CMISAction.CREATE_DOCUMENT.value,
|
||||
CMISAction.CREATE_FOLDER.value,
|
||||
CMISAction.DELETE_OBJECT.value,
|
||||
CMISAction.DELETE_TREE.value,
|
||||
}
|
||||
)
|
||||
projection["allowable_actions"] = sorted(actions)
|
||||
return projection
|
||||
|
||||
def _cmis_document_projections(
|
||||
self,
|
||||
@@ -2361,6 +2739,7 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
"query",
|
||||
"object",
|
||||
"children",
|
||||
"parent",
|
||||
"parents",
|
||||
"properties",
|
||||
"allowableActions",
|
||||
@@ -2370,6 +2749,99 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
},
|
||||
)
|
||||
|
||||
async def browser_action_payload(request: Request) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = dict(request.query_params)
|
||||
content_type = request.headers.get("content-type", "")
|
||||
content_type_lower = content_type.lower()
|
||||
body = await request.body()
|
||||
if body:
|
||||
if "application/json" in content_type_lower:
|
||||
payload.update(json.loads(body.decode("utf-8")))
|
||||
elif "multipart/form-data" in content_type_lower:
|
||||
form_values, file_values = _parse_multipart_form(content_type, body)
|
||||
payload.update(_flatten_form_values(form_values))
|
||||
for field_name, file_value in file_values.items():
|
||||
payload[field_name] = file_value["content"]
|
||||
if field_name in {"content", "contentStream", "file"} or "content" not in payload:
|
||||
payload["content"] = file_value["content"]
|
||||
payload.setdefault("media_type", file_value.get("content_type") or "application/octet-stream")
|
||||
payload.setdefault("content_filename", file_value.get("filename"))
|
||||
else:
|
||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
payload.update(_flatten_form_values(parsed))
|
||||
properties = _cmis_browser_properties(payload)
|
||||
if properties:
|
||||
payload["properties"] = properties
|
||||
payload.setdefault("name", properties.get("cmis:name"))
|
||||
payload.setdefault("type_id", properties.get("cmis:objectTypeId"))
|
||||
return payload
|
||||
|
||||
async def cmis_browser_post_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
*,
|
||||
default_object_id: str | None,
|
||||
context: OperationContext,
|
||||
) -> Any:
|
||||
payload = await browser_action_payload(request)
|
||||
action = payload.get("cmisaction") or payload.get("cmisAction") or payload.get("action")
|
||||
if action is None:
|
||||
type_id = payload.get("type_id") or payload.get("properties", {}).get("cmis:objectTypeId")
|
||||
if type_id in {CMISBaseType.DOCUMENT.value, "kontextual:document"}:
|
||||
action = "createDocument"
|
||||
elif type_id in {CMISBaseType.FOLDER.value, "kontextual:folder"}:
|
||||
action = "createFolder"
|
||||
object_id = payload.get("objectId") or payload.get("object_id") or default_object_id
|
||||
if action == "createFolder":
|
||||
return response(
|
||||
runtime.cmis_browser_create_folder,
|
||||
access_point_id,
|
||||
payload,
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action == "createDocument":
|
||||
return response(
|
||||
runtime.cmis_browser_create_document,
|
||||
access_point_id,
|
||||
payload,
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action in {"delete", "deleteObject"}:
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteObject"})
|
||||
return response(runtime.cmis_delete_object, access_point_id, object_id, payload, context)
|
||||
if action == "deleteTree":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteTree"})
|
||||
return response(runtime.cmis_delete_tree, access_point_id, object_id, payload, context)
|
||||
if action == "updateProperties":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "updateProperties"})
|
||||
response(runtime.cmis_update_properties, access_point_id, object_id, payload, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
|
||||
if action == "setContentStream":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "setContentStream"})
|
||||
response(runtime.cmis_set_content_stream, access_point_id, object_id, payload, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS Browser Binding action",
|
||||
details={
|
||||
"cmisaction": action,
|
||||
"supported": [
|
||||
"createFolder",
|
||||
"createDocument",
|
||||
"delete",
|
||||
"deleteObject",
|
||||
"deleteTree",
|
||||
"updateProperties",
|
||||
"setContentStream",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
|
||||
def cmis_browser_entry(
|
||||
access_point_id: str,
|
||||
@@ -2419,16 +2891,33 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
)
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser", tags=["cmis"])
|
||||
async def cmis_browser_entry_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
objectId: str | None = Query(None),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
return await cmis_browser_post_action(
|
||||
access_point_id,
|
||||
request,
|
||||
default_object_id=objectId,
|
||||
context=context,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/root", tags=["cmis"])
|
||||
def cmis_browser_root(
|
||||
access_point_id: str,
|
||||
cmisselector: str | None = Query(None),
|
||||
objectId: str | None = Query(None),
|
||||
path: str | None = Query(None),
|
||||
skipCount: int = Query(0),
|
||||
maxItems: int = Query(100),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
if cmisselector in (None, "", "object"):
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)
|
||||
if cmisselector == "children":
|
||||
return response(
|
||||
@@ -2439,13 +2928,21 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
skip_count=skipCount,
|
||||
max_items=maxItems,
|
||||
)
|
||||
if cmisselector == "parent":
|
||||
if not objectId:
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
return response(runtime.cmis_browser_parent, access_point_id, objectId, context)
|
||||
if cmisselector == "parents":
|
||||
if not objectId:
|
||||
return []
|
||||
return response(runtime.cmis_browser_parents, access_point_id, objectId, context)
|
||||
if cmisselector == "properties":
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["properties"]
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"]
|
||||
if cmisselector == "allowableActions":
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["allowableActions"]
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"]
|
||||
if cmisselector == "policies":
|
||||
return []
|
||||
@@ -2466,6 +2963,20 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
)
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/root", tags=["cmis"])
|
||||
async def cmis_browser_root_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
objectId: str | None = Query(None),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
return await cmis_browser_post_action(
|
||||
access_point_id,
|
||||
request,
|
||||
default_object_id=objectId or "cmis-root",
|
||||
context=context,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
|
||||
def cmis_types(access_point_id: str) -> dict[str, Any]:
|
||||
return response(runtime.cmis_type_definitions, access_point_id)
|
||||
@@ -2546,6 +3057,14 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_create_document, access_point_id, payload, context)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/folder", tags=["cmis"])
|
||||
def cmis_create_folder(
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_create_folder, access_point_id, payload, context)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/object/{object_id:path}/properties", tags=["cmis"])
|
||||
def cmis_update_properties(
|
||||
access_point_id: str,
|
||||
@@ -3073,6 +3592,81 @@ def _path_parent(path: str) -> str:
|
||||
return "/" + "/".join(parts[:-1])
|
||||
|
||||
|
||||
def _path_name(path: str) -> str:
|
||||
normalized = _normalize_cmis_path(path)
|
||||
if normalized == "/":
|
||||
return "root"
|
||||
return normalized.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _path_contains(parent_path: str, candidate_path: str) -> bool:
|
||||
parent = _normalize_cmis_path(parent_path)
|
||||
candidate = _normalize_cmis_path(candidate_path)
|
||||
if parent == "/":
|
||||
return candidate != "/"
|
||||
return candidate == parent or candidate.startswith(parent.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _parse_multipart_form(content_type: str, body: bytes) -> tuple[dict[str, list[Any]], dict[str, dict[str, Any]]]:
|
||||
message = BytesParser(policy=policy.default).parsebytes(
|
||||
b"Content-Type: " + content_type.encode("utf-8") + b"\r\nMIME-Version: 1.0\r\n\r\n" + body
|
||||
)
|
||||
values: dict[str, list[Any]] = {}
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
if not message.is_multipart():
|
||||
return values, files
|
||||
for part in message.iter_parts():
|
||||
disposition = part.get("content-disposition", "")
|
||||
if "form-data" not in disposition:
|
||||
continue
|
||||
field_name = part.get_param("name", header="content-disposition")
|
||||
if not field_name:
|
||||
continue
|
||||
content = part.get_payload(decode=True) or b""
|
||||
filename = part.get_filename()
|
||||
if filename is not None:
|
||||
files[field_name] = {
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
"content_type": part.get_content_type(),
|
||||
}
|
||||
continue
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
values.setdefault(field_name, []).append(content.decode(charset, errors="replace"))
|
||||
return values, files
|
||||
|
||||
|
||||
def _flatten_form_values(values: dict[str, list[Any]]) -> dict[str, Any]:
|
||||
flattened: dict[str, Any] = {}
|
||||
for key, items in values.items():
|
||||
if len(items) == 1:
|
||||
flattened[key] = items[0]
|
||||
else:
|
||||
flattened[key] = items
|
||||
return flattened
|
||||
|
||||
|
||||
def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
properties = dict(payload.get("properties", {}))
|
||||
property_ids: dict[str, str] = {}
|
||||
property_values: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
if key.startswith("propertyId["):
|
||||
index = key[len("propertyId[") :].split("]", 1)[0]
|
||||
property_ids[index] = str(value)
|
||||
elif key.startswith("propertyValue["):
|
||||
index = key[len("propertyValue[") :].split("]", 1)[0]
|
||||
property_values[index] = value
|
||||
for index, property_id in property_ids.items():
|
||||
if property_id:
|
||||
properties[property_id] = property_values.get(index)
|
||||
if "cmis:name" not in properties and payload.get("name"):
|
||||
properties["cmis:name"] = payload["name"]
|
||||
if "cmis:objectTypeId" not in properties and payload.get("type_id"):
|
||||
properties["cmis:objectTypeId"] = payload["type_id"]
|
||||
return properties
|
||||
|
||||
|
||||
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
|
||||
return AuthorizationError(
|
||||
"CMIS operation denied by access-point profile",
|
||||
|
||||
@@ -55,9 +55,11 @@ class CMISAction(str, Enum):
|
||||
QUERY = "query"
|
||||
GET_RELATIONSHIPS = "get_relationships"
|
||||
GET_CHANGE_LOG = "get_change_log"
|
||||
CREATE_FOLDER = "create_folder"
|
||||
CREATE_DOCUMENT = "create_document"
|
||||
UPDATE_PROPERTIES = "update_properties"
|
||||
DELETE_OBJECT = "delete_object"
|
||||
DELETE_TREE = "delete_tree"
|
||||
SET_CONTENT_STREAM = "set_content_stream"
|
||||
APPLY_ACL = "apply_acl"
|
||||
APPLY_POLICY = "apply_policy"
|
||||
@@ -84,9 +86,11 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
|
||||
CMISAction.QUERY: CMISCapability.DISCOVERY_QUERY,
|
||||
CMISAction.GET_RELATIONSHIPS: CMISCapability.RELATIONSHIPS,
|
||||
CMISAction.GET_CHANGE_LOG: CMISCapability.CHANGE_LOG,
|
||||
CMISAction.CREATE_FOLDER: CMISCapability.OBJECT_WRITE,
|
||||
CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE,
|
||||
CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE,
|
||||
CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE,
|
||||
CMISAction.DELETE_TREE: CMISCapability.OBJECT_WRITE,
|
||||
CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE,
|
||||
CMISAction.APPLY_ACL: CMISCapability.ACL,
|
||||
CMISAction.APPLY_POLICY: CMISCapability.POLICY,
|
||||
@@ -104,16 +108,20 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
|
||||
CMISAction.QUERY,
|
||||
CMISAction.GET_RELATIONSHIPS,
|
||||
CMISAction.GET_CHANGE_LOG,
|
||||
CMISAction.CREATE_FOLDER,
|
||||
CMISAction.CREATE_DOCUMENT,
|
||||
CMISAction.UPDATE_PROPERTIES,
|
||||
CMISAction.DELETE_OBJECT,
|
||||
CMISAction.DELETE_TREE,
|
||||
CMISAction.SET_CONTENT_STREAM,
|
||||
}
|
||||
)
|
||||
MUTATION_ACTIONS = {
|
||||
CMISAction.CREATE_DOCUMENT,
|
||||
CMISAction.CREATE_FOLDER,
|
||||
CMISAction.UPDATE_PROPERTIES,
|
||||
CMISAction.DELETE_OBJECT,
|
||||
CMISAction.DELETE_TREE,
|
||||
CMISAction.SET_CONTENT_STREAM,
|
||||
CMISAction.APPLY_ACL,
|
||||
CMISAction.APPLY_POLICY,
|
||||
@@ -670,7 +678,7 @@ class CMISDomainMapper:
|
||||
can_write = self.access_point.profile.allow_mutations
|
||||
return [
|
||||
_type_definition(CMISBaseType.DOCUMENT, "kontextual:document", "Kontextual Document", can_write),
|
||||
_type_definition(CMISBaseType.FOLDER, "kontextual:folder", "Kontextual Folder", False),
|
||||
_type_definition(CMISBaseType.FOLDER, "kontextual:folder", "Kontextual Folder", can_write),
|
||||
_type_definition(
|
||||
CMISBaseType.RELATIONSHIP,
|
||||
"kontextual:relationship",
|
||||
@@ -874,6 +882,9 @@ class CMISDomainMapper:
|
||||
"cmis:creationDate": asset.created_at,
|
||||
"cmis:lastModificationDate": asset.updated_at,
|
||||
"cmis:changeToken": asset.current_version_id,
|
||||
"cmis:description": asset.metadata.get("description", asset.title),
|
||||
"cmis:secondaryObjectTypeIds": list(asset.metadata.get("cmis_secondary_object_type_ids", ())),
|
||||
"cmis:path": self.asset_path(asset),
|
||||
"cmis:contentStreamLength": content_stream.get("length") if content_stream else None,
|
||||
"cmis:contentStreamMimeType": content_stream.get("mime_type") if content_stream else None,
|
||||
"cmis:contentStreamFileName": content_stream.get("file_name") if content_stream else None,
|
||||
@@ -890,7 +901,14 @@ class CMISDomainMapper:
|
||||
key = getattr(record, "key", None)
|
||||
if key:
|
||||
properties[f"kontextual:metadata:{key}"] = getattr(record, "value", None)
|
||||
return compact_dict(properties)
|
||||
compacted = compact_dict(properties)
|
||||
compacted.setdefault("cmis:createdBy", "system")
|
||||
compacted.setdefault("cmis:lastModifiedBy", "system")
|
||||
compacted.setdefault("cmis:secondaryObjectTypeIds", [])
|
||||
compacted.setdefault("kontextual:owner", "")
|
||||
compacted.setdefault("kontextual:topics", [])
|
||||
compacted.setdefault("kontextual:reviewState", "")
|
||||
return compacted
|
||||
|
||||
def map_content_stream(
|
||||
self,
|
||||
@@ -925,6 +943,12 @@ class CMISDomainMapper:
|
||||
"cmis:isLatestMajorVersion": True,
|
||||
"cmis:versionSeriesId": f"cmis:version-series:{asset.id}",
|
||||
"cmis:versionLabel": "1",
|
||||
"cmis:isVersionSeriesCheckedOut": False,
|
||||
"cmis:isPrivateWorkingCopy": False,
|
||||
"cmis:versionSeriesCheckedOutBy": None,
|
||||
"cmis:versionSeriesCheckedOutId": None,
|
||||
"cmis:checkinComment": "",
|
||||
"cmis:isImmutable": False,
|
||||
}
|
||||
latest_sequence = max((version.sequence for version in versions), default=current_version.sequence)
|
||||
return {
|
||||
@@ -933,6 +957,12 @@ class CMISDomainMapper:
|
||||
"cmis:isLatestMajorVersion": current_version.sequence == latest_sequence,
|
||||
"cmis:versionSeriesId": f"cmis:version-series:{asset.id}",
|
||||
"cmis:versionLabel": str(current_version.sequence),
|
||||
"cmis:isVersionSeriesCheckedOut": False,
|
||||
"cmis:isPrivateWorkingCopy": False,
|
||||
"cmis:versionSeriesCheckedOutBy": None,
|
||||
"cmis:versionSeriesCheckedOutId": None,
|
||||
"cmis:checkinComment": "",
|
||||
"cmis:isImmutable": False,
|
||||
"kontextual:versionId": current_version.version_id,
|
||||
"kontextual:versionChangeType": current_version.change_type.value,
|
||||
}
|
||||
@@ -1187,7 +1217,10 @@ def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]:
|
||||
for key, value in properties.items()
|
||||
},
|
||||
"succinctProperties": properties,
|
||||
"allowableActions": cmis_browser_allowable_actions(projection.get("allowable_actions", [])),
|
||||
"allowableActions": cmis_browser_allowable_actions(
|
||||
projection.get("allowable_actions", []),
|
||||
base_type_id=properties.get("cmis:baseTypeId"),
|
||||
),
|
||||
}
|
||||
acl = projection.get("acl")
|
||||
if isinstance(acl, dict) and acl:
|
||||
@@ -1292,8 +1325,13 @@ def cmis_browser_property_value(property_id: str, value: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict[str, bool]:
|
||||
def cmis_browser_allowable_actions(
|
||||
actions: list[str] | tuple[str, ...],
|
||||
*,
|
||||
base_type_id: str | None = None,
|
||||
) -> dict[str, bool]:
|
||||
native = set(actions)
|
||||
is_folder = base_type_id == CMISBaseType.FOLDER.value
|
||||
return {
|
||||
"canGetObjectParents": CMISAction.GET_OBJECT_PARENTS.value in native,
|
||||
"canGetProperties": CMISAction.GET_OBJECT.value in native,
|
||||
@@ -1305,13 +1343,13 @@ def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict
|
||||
"canSetContentStream": CMISAction.SET_CONTENT_STREAM.value in native,
|
||||
"canGetChildren": CMISAction.GET_CHILDREN.value in native,
|
||||
"canCreateDocument": CMISAction.CREATE_DOCUMENT.value in native,
|
||||
"canCreateFolder": False,
|
||||
"canCreateFolder": CMISAction.CREATE_FOLDER.value in native,
|
||||
"canCreateRelationship": False,
|
||||
"canCreateItem": False,
|
||||
"canDeleteTree": False,
|
||||
"canDeleteTree": CMISAction.DELETE_TREE.value in native,
|
||||
"canGetDescendants": False,
|
||||
"canGetFolderTree": False,
|
||||
"canGetFolderParent": False,
|
||||
"canGetFolderParent": is_folder and CMISAction.GET_OBJECT_PARENTS.value in native,
|
||||
"canGetRenditions": False,
|
||||
"canMoveObject": False,
|
||||
"canAddObjectToFolder": False,
|
||||
@@ -1329,6 +1367,9 @@ def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict
|
||||
|
||||
|
||||
def cmis_browser_root_folder(access_point: CMISAccessPoint) -> dict[str, Any]:
|
||||
allowable_actions = [CMISAction.GET_OBJECT.value, CMISAction.GET_CHILDREN.value]
|
||||
if access_point.profile.allow_mutations:
|
||||
allowable_actions.extend([CMISAction.CREATE_DOCUMENT.value, CMISAction.CREATE_FOLDER.value])
|
||||
return {
|
||||
"object_id": access_point.root_folder_id,
|
||||
"base_type_id": CMISBaseType.FOLDER.value,
|
||||
@@ -1352,8 +1393,10 @@ def cmis_browser_root_folder(access_point: CMISAccessPoint) -> dict[str, Any]:
|
||||
"cmis:allowedChildObjectTypeIds": [CMISBaseType.DOCUMENT.value, CMISBaseType.FOLDER.value],
|
||||
"kontextual:sensitivity": "internal",
|
||||
"kontextual:lifecycle": LifecycleState.ACTIVE.value,
|
||||
"kontextual:filingSource": "root",
|
||||
"kontextual:workspaceFolder": False,
|
||||
},
|
||||
"allowable_actions": [CMISAction.GET_OBJECT.value, CMISAction.GET_CHILDREN.value],
|
||||
"allowable_actions": allowable_actions,
|
||||
}
|
||||
|
||||
|
||||
@@ -1545,6 +1588,7 @@ def _browser_propdef(
|
||||
|
||||
def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]:
|
||||
properties = dict(projection.get("properties", {}))
|
||||
properties.update(projection.get("version", {}))
|
||||
base_type = projection.get("base_type_id") or properties.get("cmis:baseTypeId")
|
||||
properties["cmis:objectId"] = projection.get("object_id", properties.get("cmis:objectId"))
|
||||
properties["cmis:name"] = projection.get("name", properties.get("cmis:name"))
|
||||
@@ -1612,8 +1656,8 @@ def _type_definition(
|
||||
"queryable": is_document,
|
||||
"controllable_acl": is_document,
|
||||
"controllable_policy": False,
|
||||
"creatable": can_write and is_document,
|
||||
"fileable": is_document,
|
||||
"creatable": can_write and base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER},
|
||||
"fileable": base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER},
|
||||
"fulltext_indexed": False,
|
||||
"included_in_supertype_query": is_document,
|
||||
"versionable": False,
|
||||
@@ -1627,6 +1671,14 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
|
||||
"cmis:name": {"property_type": "string", "cardinality": "single", "required": True},
|
||||
"cmis:baseTypeId": {"property_type": "id", "cardinality": "single", "required": True},
|
||||
"cmis:objectTypeId": {"property_type": "id", "cardinality": "single", "required": True},
|
||||
"cmis:createdBy": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"cmis:lastModifiedBy": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"cmis:creationDate": {"property_type": "datetime", "cardinality": "single", "required": False},
|
||||
"cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False},
|
||||
"cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False},
|
||||
"cmis:path": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"cmis:description": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
"kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False},
|
||||
}
|
||||
@@ -1653,8 +1705,109 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:assetId": {
|
||||
"property_type": "id",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:assetType": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:owner": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:topics": {
|
||||
"property_type": "string",
|
||||
"cardinality": "multi",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:reviewState": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isImmutable": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isLatestVersion": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isMajorVersion": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isLatestMajorVersion": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:versionLabel": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:versionSeriesId": {
|
||||
"property_type": "id",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isVersionSeriesCheckedOut": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:isPrivateWorkingCopy": {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:versionSeriesCheckedOutBy": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:versionSeriesCheckedOutId": {
|
||||
"property_type": "id",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"cmis:checkinComment": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:versionId": {
|
||||
"property_type": "id",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
"kontextual:versionChangeType": {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
if base_type_id == CMISBaseType.FOLDER:
|
||||
definitions["kontextual:filingSource"] = {
|
||||
"property_type": "string",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
}
|
||||
definitions["kontextual:workspaceFolder"] = {
|
||||
"property_type": "boolean",
|
||||
"cardinality": "single",
|
||||
"required": False,
|
||||
}
|
||||
if base_type_id == CMISBaseType.RELATIONSHIP:
|
||||
definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True}
|
||||
definitions["cmis:targetId"] = {"property_type": "id", "cardinality": "single", "required": True}
|
||||
|
||||
@@ -90,6 +90,7 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N
|
||||
assert "/cmis/{access_point_id}/browser/query" in paths
|
||||
assert "/cmis/{access_point_id}/browser/relationships" in paths
|
||||
assert "/cmis/{access_point_id}/browser/changes" in paths
|
||||
assert "/cmis/{access_point_id}/browser/folder" in paths
|
||||
|
||||
|
||||
def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
|
||||
@@ -117,6 +118,10 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
|
||||
"/cmis/readonly-browser/browser/root",
|
||||
params={"cmisselector": "policies"},
|
||||
).json()
|
||||
root_object = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/root",
|
||||
params={"cmisselector": "object"},
|
||||
).json()
|
||||
|
||||
assert access_points["count"] == 4
|
||||
assert repository["repositoryId"] == "kontextual-readonly-browser"
|
||||
@@ -133,6 +138,11 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
|
||||
assert "propertyDefinitions" in browser_type_definition
|
||||
assert browser_type_descendants[0]["children"] == []
|
||||
assert root_policies == []
|
||||
assert root_object["properties"]["kontextual:filingSource"]["value"] == "root"
|
||||
assert root_object["properties"]["kontextual:workspaceFolder"]["value"] is False
|
||||
assert "kontextual:assetId" in browser_type_definition["propertyDefinitions"]
|
||||
assert browser_type_definition["propertyDefinitions"]["kontextual:topics"]["cardinality"] == "multi"
|
||||
assert "cmis:path" in browser_type_definition["propertyDefinitions"]
|
||||
assert {item["base_type_id"] for item in types["items"]} >= {
|
||||
"cmis:document",
|
||||
"cmis:folder",
|
||||
@@ -244,13 +254,115 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
|
||||
assert deleted.json()["lifecycle"] == "delete_requested"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None:
|
||||
created = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createFolder",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:folder",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Action Workspace",
|
||||
},
|
||||
)
|
||||
folder = created.json()
|
||||
folder_id = folder["properties"]["cmis:objectId"]["value"]
|
||||
root_children = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "children"},
|
||||
).json()
|
||||
fetched = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "objectId": folder_id},
|
||||
).json()
|
||||
parent = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "parent", "objectId": folder_id},
|
||||
).json()
|
||||
document = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"objectId": folder_id},
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Multipart Document",
|
||||
},
|
||||
files={"content": ("multipart.txt", b"Multipart content", "text/plain")},
|
||||
)
|
||||
document_path = document.json()["properties"]["cmis:path"]["value"]
|
||||
fetched_document_by_path = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "path": document_path},
|
||||
).json()
|
||||
document_parents = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "parents", "objectId": document.json()["properties"]["cmis:objectId"]["value"]},
|
||||
).json()
|
||||
fetched_folder_by_path = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "path": "/Action Workspace"},
|
||||
).json()
|
||||
deleted_tree = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={"cmisaction": "deleteTree", "objectId": folder_id},
|
||||
)
|
||||
root_children_after_delete = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "children"},
|
||||
).json()
|
||||
fetched_after_delete = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "objectId": folder_id},
|
||||
)
|
||||
deleted = cmis_client.post(
|
||||
"/cmis/compat-tck/browser",
|
||||
data={"cmisaction": "delete", "objectId": folder_id},
|
||||
)
|
||||
|
||||
assert created.status_code == 200
|
||||
assert folder["properties"]["cmis:name"]["value"] == "Action Workspace"
|
||||
assert folder["properties"]["kontextual:workspaceFolder"]["value"] is True
|
||||
assert folder["allowableActions"]["canDeleteTree"] is True
|
||||
assert any(item["object"]["properties"]["cmis:objectId"]["value"] == folder_id for item in root_children["objects"])
|
||||
assert fetched["properties"]["cmis:path"]["value"] == "/Action Workspace"
|
||||
assert parent["properties"]["cmis:objectId"]["value"] == "cmis-root"
|
||||
assert document.status_code == 200
|
||||
assert document.json()["properties"]["cmis:name"]["value"] == "Multipart Document"
|
||||
assert document.json()["properties"]["cmis:contentStreamLength"]["value"] == 17
|
||||
assert document.json()["properties"]["cmis:isLatestVersion"]["value"] is True
|
||||
assert document.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == []
|
||||
assert document.json()["allowableActions"]["canGetFolderParent"] is False
|
||||
assert document_path == "/Action Workspace/Multipart Document"
|
||||
assert fetched_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document"
|
||||
assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Action Workspace"
|
||||
assert fetched_folder_by_path["properties"]["cmis:objectId"]["value"] == folder_id
|
||||
assert deleted_tree.json()["failedToDelete"] == []
|
||||
assert all(
|
||||
item["object"]["properties"]["cmis:objectId"]["value"] != folder_id
|
||||
for item in root_children_after_delete["objects"]
|
||||
)
|
||||
assert fetched_after_delete.status_code == 404
|
||||
assert deleted.status_code == 404
|
||||
|
||||
|
||||
def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None:
|
||||
response = cmis_client.post(
|
||||
"/cmis/readonly-browser/browser/document",
|
||||
json={"asset_id": "asset-api-readonly-denied", "name": "Denied"},
|
||||
)
|
||||
folder_response = cmis_client.post(
|
||||
"/cmis/readonly-browser/browser/root",
|
||||
data={
|
||||
"cmisaction": "createFolder",
|
||||
"propertyId[0]": "cmis:name",
|
||||
"propertyValue[0]": "Denied Folder",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert folder_response.status_code == 403
|
||||
|
||||
|
||||
def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None:
|
||||
|
||||
@@ -190,6 +190,58 @@ def test_runtime_cmis_governed_authoring_allows_selected_mutations(cmis_runtime)
|
||||
assert "CMIS object not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
folder = runtime.cmis_create_folder(
|
||||
"compat-tck",
|
||||
{"name": "TCK Workspace", "properties": {"cmis:objectTypeId": "cmis:folder"}},
|
||||
context,
|
||||
)
|
||||
folder_object_id = folder["object_id"]
|
||||
root_children = runtime.cmis_children("compat-tck", context)
|
||||
fetched = runtime.cmis_object("compat-tck", folder_object_id, context)
|
||||
parents = runtime.cmis_object_parents("compat-tck", folder_object_id, context)
|
||||
document = runtime.cmis_create_document(
|
||||
"compat-tck",
|
||||
{
|
||||
"name": "Workspace Document",
|
||||
"folder_id": folder_object_id,
|
||||
"content": "Workspace content",
|
||||
"media_type": "text/plain",
|
||||
},
|
||||
context,
|
||||
)
|
||||
folder_children = runtime.cmis_children("compat-tck", context, folder_id=folder_object_id)
|
||||
document_by_path = runtime.cmis_object_by_path("compat-tck", "/TCK Workspace/Workspace Document", context)
|
||||
document_parents = runtime.cmis_object_parents("compat-tck", document["object_id"], context)
|
||||
|
||||
assert folder["path"] == "/TCK Workspace"
|
||||
assert folder["properties"]["kontextual:workspaceFolder"] is True
|
||||
assert folder_object_id in {item["object_id"] for item in root_children["objects"]}
|
||||
assert fetched["properties"]["cmis:path"] == "/TCK Workspace"
|
||||
assert parents["parents"][0]["object_id"] == "cmis-root"
|
||||
assert document["path"] == "/TCK Workspace/Workspace Document"
|
||||
assert document["properties"]["cmis:path"] == "/TCK Workspace/Workspace Document"
|
||||
assert document_by_path["object_id"] == document["object_id"]
|
||||
assert document_parents["count"] == 1
|
||||
assert document_parents["parents"][0]["properties"]["cmis:path"] == "/TCK Workspace"
|
||||
assert document["object_id"] in {item["object_id"] for item in folder_children["objects"]}
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
runtime.cmis_delete_object("compat-tck", folder_object_id, {}, context)
|
||||
assert "CMIS folder is not empty" in str(exc_info.value)
|
||||
|
||||
deleted_tree = runtime.cmis_delete_tree("compat-tck", folder_object_id, {}, context)
|
||||
root_children_after_delete = runtime.cmis_children("compat-tck", context)
|
||||
|
||||
assert deleted_tree["failedToDelete"] == []
|
||||
assert folder_object_id not in {item["object_id"] for item in root_children_after_delete["objects"]}
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
runtime.cmis_object("compat-tck", folder_object_id, context)
|
||||
assert "CMIS folder not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
@@ -216,6 +268,15 @@ def test_runtime_cmis_readonly_profile_rejects_mutations(cmis_runtime) -> None:
|
||||
|
||||
assert "CMIS operation denied" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
runtime.cmis_create_folder(
|
||||
"readonly-browser",
|
||||
{"name": "Denied Folder"},
|
||||
context,
|
||||
)
|
||||
|
||||
assert "CMIS operation denied" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_cmis_acl_projection_and_redaction(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "CMIS Object/Content Maturity Expansion"
|
||||
domain: markitect
|
||||
repo: kontextual-engine
|
||||
status: planned
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: markitect
|
||||
planning_priority: high
|
||||
@@ -90,11 +90,48 @@ Disallowed architectural moves:
|
||||
- The maturity scorecard is updated from fresh TCK evidence, with remaining
|
||||
unsupported features explicitly classified.
|
||||
|
||||
## Implementation Evidence - 2026-05-08
|
||||
|
||||
Evidence file:
|
||||
|
||||
- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md`
|
||||
|
||||
Implemented in this pass:
|
||||
|
||||
- Profile-scoped CMIS workspace folder registry.
|
||||
- Browser Binding `createFolder`, multipart/form `createDocument`,
|
||||
`deleteTree`, `parent`, and `getObjectByPath` support.
|
||||
- Workspace folder deletion that removes adapter-managed folders rather than
|
||||
falling back to phantom virtual folder projections.
|
||||
- Full parent folder projections for `getObjectParents`, including `cmis:path`
|
||||
for OpenCMIS `getPaths()`.
|
||||
- Document/folder type metadata alignment for emitted CMIS and Kontextual
|
||||
properties.
|
||||
- CMIS document version/read-side property projection while keeping versioning
|
||||
operations unsupported.
|
||||
- Single-parent projection for CMIS-authored documents when repository
|
||||
multifiling remains advertised as unsupported.
|
||||
|
||||
Latest verification:
|
||||
|
||||
- Internal: `.venv/bin/python -m pytest tests/cmis --perf-history-disable`
|
||||
-> `47 passed`.
|
||||
- OpenCMIS: `run-20260508T134448Z` in
|
||||
`/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z`.
|
||||
|
||||
Current external frontier:
|
||||
|
||||
- OpenCMIS now reaches action/operation-context maturity gaps:
|
||||
`cmisaction=update`, `cmisaction=move`, filter trimming, async
|
||||
`getObjectByPath`, and MIME normalization.
|
||||
These are follow-up maturity items rather than the original folder-creatable
|
||||
blocker.
|
||||
|
||||
## D14.1 - Define CMIS maturity boundary and TCK profile semantics
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T001
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "333f8ea0-0582-467d-a52d-7ef5cf6f34c0"
|
||||
```
|
||||
@@ -112,7 +149,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T002
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "30d02544-0325-490e-84d7-ebaa3825ee78"
|
||||
```
|
||||
@@ -131,7 +168,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T003
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b5324bcb-67fe-4f28-9591-83e6361bfd01"
|
||||
```
|
||||
@@ -150,7 +187,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T004
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: high
|
||||
state_hub_task_id: "f9323c25-4d81-42cd-b7e6-e40d7e0487cd"
|
||||
```
|
||||
@@ -190,7 +227,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T006
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: medium
|
||||
state_hub_task_id: "b1562023-807b-4fed-b794-6930fcc2274e"
|
||||
```
|
||||
@@ -228,7 +265,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T008
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c9514345-793c-489e-9dcc-86776db47cf4"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user