diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index 2cb023df4..eb04f6a06 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -131,6 +131,32 @@ print(f"Created CurationTask: {task_id}") - Automatic schema binding to the folder for validation - Optional wiki attached to the folder +### Controlling who can access the grid session + +Both `create_record_based_metadata_task` and `create_file_based_metadata_task` accept an optional `suggested_authorization_mode` that tells clients how to scope access when a grid session is created for the task: + +- `SESSION_OWNER` (the server default applied when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. +- `SOURCE_BENEFACTOR` extends access to anyone with `EDIT` rights on the source entity. Use it when curation should be open to all editors of the source. + +```python +from synapseclient.models import AuthorizationMode + +entity_view_id, task_id = create_file_based_metadata_task( + synapse_client=syn, + folder_id="syn987654321", + curation_task_name="FileMetadata_Curation", + instructions="Annotate each file with metadata according to the schema requirements.", + entity_view_name="Animal Study Files View", + schema_uri=schema_uri, + suggested_authorization_mode="SOURCE_BENEFACTOR", +) +``` + +When [CurationTask.create_grid_session][synapseclient.models.CurationTask.create_grid_session] is later called, it forwards this mode to the new grid session and the server uses it to determine access: `SESSION_OWNER` limits access to the session owner (the caller, or an explicit `owner_principal_id`) and their team, while `SOURCE_BENEFACTOR` lets the session inherit access from the source entity's benefactor. + +!!! warning "Changing the mode invalidates the active session" + Updating `suggested_authorization_mode` on an existing task causes the server to automatically clear `activeSessionId` on the task status. The previously active grid session is no longer linked to the task, so you must create a new grid session before curation can continue. + ## Complete example script Here's the full script that demonstrates both workflow types: diff --git a/docs/reference/experimental/async/curator.md b/docs/reference/experimental/async/curator.md index 1b1654e2a..6908bfa9f 100644 --- a/docs/reference/experimental/async/curator.md +++ b/docs/reference/experimental/async/curator.md @@ -54,6 +54,12 @@ at your own risk. inherited_members: true members: --- +[](){ #AuthorizationMode-reference-async } +::: synapseclient.models.AuthorizationMode + options: + inherited_members: true + members: +--- [](){ #grid-reference-async } ::: synapseclient.models.Grid options: diff --git a/docs/reference/experimental/sync/curator.md b/docs/reference/experimental/sync/curator.md index f0866edef..ad5a3eab0 100644 --- a/docs/reference/experimental/sync/curator.md +++ b/docs/reference/experimental/sync/curator.md @@ -54,6 +54,12 @@ at your own risk. inherited_members: true members: --- +[](){ #AuthorizationMode-reference } +::: synapseclient.models.AuthorizationMode + options: + inherited_members: true + members: +--- [](){ #grid-reference } ::: synapseclient.models.Grid options: diff --git a/synapseclient/extensions/curator/file_based_metadata_task.py b/synapseclient/extensions/curator/file_based_metadata_task.py index cd2a39191..287685dd1 100644 --- a/synapseclient/extensions/curator/file_based_metadata_task.py +++ b/synapseclient/extensions/curator/file_based_metadata_task.py @@ -12,6 +12,7 @@ from synapseclient.core.exceptions import SynapseHTTPError # type: ignore from synapseclient.extensions.curator.utils import project_id_from_entity_id from synapseclient.models import ( # type: ignore + AuthorizationMode, Column, ColumnType, EntityView, @@ -325,6 +326,7 @@ def create_file_based_metadata_task( enable_derived_annotations: bool = False, assignee_principal_id: Optional[Union[str, int]] = None, view_type_mask: Union[int, ViewTypeMask] = ViewTypeMask.FILE, + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, *, synapse_client: Optional[Synapse] = None, ) -> Tuple[str, str]: @@ -338,7 +340,7 @@ def create_file_based_metadata_task( ```python import synapseclient from synapseclient.extensions.curator import create_file_based_metadata_task - from synapseclient.models import ViewTypeMask + from synapseclient.models import AuthorizationMode, ViewTypeMask syn = synapseclient.Synapse() syn.login() @@ -351,8 +353,9 @@ def create_file_based_metadata_task( attach_wiki=False, entity_view_name="Biospecimen Metadata View", schema_uri="sage.schemas.v2571-amp.Biospecimen.schema-0.0.1", - assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) - view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, # Optional: include additional entity types in the view + assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) + view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, # Optional: include additional entity types in the view + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, ) ``` @@ -377,6 +380,19 @@ def create_file_based_metadata_task( ViewTypeMask.FILE. Additional types can be added using bitwise OR (e.g., ViewTypeMask.FILE | ViewTypeMask.DOCKER). Accepts either a ViewTypeMask enum member or its raw integer value. + suggested_authorization_mode: Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -481,6 +497,7 @@ def create_file_based_metadata_task( task_properties=FileBasedMetadataTaskProperties( upload_folder_id=folder_id, file_view_id=entity_view_id, + suggested_authorization_mode=suggested_authorization_mode, ), ).store(synapse_client=synapse_client) except Exception as e: diff --git a/synapseclient/extensions/curator/record_based_metadata_task.py b/synapseclient/extensions/curator/record_based_metadata_task.py index c97a49260..beeeae526 100644 --- a/synapseclient/extensions/curator/record_based_metadata_task.py +++ b/synapseclient/extensions/curator/record_based_metadata_task.py @@ -14,6 +14,7 @@ from synapseclient.core.utils import test_import_pandas from synapseclient.extensions.curator.utils import project_id_from_entity_id from synapseclient.models import ( + AuthorizationMode, CurationTask, Grid, JSONSchema, @@ -111,6 +112,7 @@ def create_record_based_metadata_task( bind_schema_to_record_set: bool = True, enable_derived_annotations: bool = False, assignee_principal_id: Optional[Union[str, int]] = None, + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, *, synapse_client: Optional[Synapse] = None, project_id: Optional[str] = None, # Deprecated, will be removed in v5.0.0 @@ -136,13 +138,13 @@ def create_record_based_metadata_task( Example: Creating a record-based metadata curation task with a schema URI In this example, we create a RecordSet and CurationTask for biospecimen metadata curation using a schema URI. By default this will also bind the schema to the - RecordSet, however the `bind_schema_to_record_set` parameter can be set to + RecordSet, however the bind_schema_to_record_set parameter can be set to False to skip that step. - ```python import synapseclient from synapseclient.extensions.curator import create_record_based_metadata_task + from synapseclient.models import AuthorizationMode syn = synapseclient.Synapse() syn.login() @@ -157,6 +159,7 @@ def create_record_based_metadata_task( instructions="Please curate this metadata according to the schema requirements", schema_uri="schema-org-schema.name.schema-v1.0.0", assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, create_grid=False, # Opt out of deprecated Grid creation ) ``` @@ -182,6 +185,19 @@ def create_record_based_metadata_task( (default), the task will be unassigned. For metadata tasks, this determines the owner of the grid session. Team members can all join grid sessions owned by their team, while user-owned grid sessions are restricted to that user only. + suggested_authorization_mode: Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -286,6 +302,7 @@ def create_record_based_metadata_task( ), task_properties=RecordBasedMetadataTaskProperties( record_set_id=record_set_id, + suggested_authorization_mode=suggested_authorization_mode, ), ).store(synapse_client=synapse_client) synapse_client.logger.info( diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index c68bfe2ec..ce1a3bf9e 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -8,6 +8,7 @@ ) from synapseclient.models.annotations import Annotations from synapseclient.models.curation import ( + AuthorizationMode, CurationTask, CurationTaskStatus, FileBasedMetadataTaskProperties, @@ -97,6 +98,7 @@ "Team", "TeamMember", "TeamMembershipStatus", + "AuthorizationMode", "CurationTask", "CurationTaskStatus", "FileBasedMetadataTaskProperties", diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 918ab0850..4412310f6 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -91,8 +91,29 @@ class TaskState(str, Enum): """The task has been canceled and is no longer needed.""" +class AuthorizationMode(str, Enum): + """ + The authorization mode a client should use when creating a linked grid session + for a CurationTask. + + See . + """ + + SESSION_OWNER = "SESSION_OWNER" + """Access is limited to the session owner or members of the owner's team. This is + the default setting. When a view serves as the source, the owner can access all + available rows, while other team members see data according to the owner's + permission scope.""" + + SOURCE_BENEFACTOR = "SOURCE_BENEFACTOR" + """Access is granted to any user who has EDIT (UPDATE) access on all benefactor IDs + captured when the session was created. This mode allows project administrators to + enable collaborative grid access for all editors without maintaining a separate + ownership team. User visibility of rows depends on their individual permissions.""" + + @dataclass -class FileBasedMetadataTaskProperties: +class FileBasedMetadataTaskProperties(EnumCoercionMixin): """ A CurationTaskProperties for file-based data, describing where data is uploaded and a view which contains the annotations. @@ -104,12 +125,36 @@ class FileBasedMetadataTaskProperties: file_view_id: The synId of the FileView that shows all data of this type """ + _ENUM_FIELDS: ClassVar[dict[str, type]] = { + "suggested_authorization_mode": AuthorizationMode + } + upload_folder_id: Optional[str] = None """The synId of the folder where data files of this type are to be uploaded""" file_view_id: Optional[str] = None """The synId of the FileView that shows all data of this type""" + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue.""" + + collaborator_principal_ids: Optional[list[str]] = None + """Not actively used at this time. + The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER""" + def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] ) -> "FileBasedMetadataTaskProperties": @@ -124,6 +169,12 @@ def fill_from_dict( """ self.upload_folder_id = synapse_response.get("uploadFolderId", None) self.file_view_id = synapse_response.get("fileViewId", None) + self.suggested_authorization_mode = synapse_response.get( + "suggestedAuthorizationMode", None + ) + self.collaborator_principal_ids = synapse_response.get( + "collaboratorPrincipalIds", None + ) return self def to_synapse_request(self) -> Dict[str, Any]: @@ -133,16 +184,23 @@ def to_synapse_request(self) -> Dict[str, Any]: Returns: A dictionary representation of this object for API requests. """ - request_dict = {"concreteType": FILE_BASED_METADATA_TASK_PROPERTIES} - if self.upload_folder_id is not None: - request_dict["uploadFolderId"] = self.upload_folder_id - if self.file_view_id is not None: - request_dict["fileViewId"] = self.file_view_id + request_dict = { + "concreteType": FILE_BASED_METADATA_TASK_PROPERTIES, + "uploadFolderId": self.upload_folder_id, + "fileViewId": self.file_view_id, + "suggestedAuthorizationMode": ( + self.suggested_authorization_mode.value + if self.suggested_authorization_mode is not None + else None + ), + "collaboratorPrincipalIds": self.collaborator_principal_ids, + } + delete_none_keys(request_dict) return request_dict @dataclass -class RecordBasedMetadataTaskProperties: +class RecordBasedMetadataTaskProperties(EnumCoercionMixin): """ A CurationTaskProperties for record-based metadata. @@ -152,9 +210,33 @@ class RecordBasedMetadataTaskProperties: record_set_id: The synId of the RecordSet that will contain all record-based metadata """ + _ENUM_FIELDS: ClassVar[dict[str, type]] = { + "suggested_authorization_mode": AuthorizationMode + } + record_set_id: Optional[str] = None """The synId of the RecordSet that will contain all record-based metadata""" + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue.""" + + collaborator_principal_ids: Optional[list[str]] = None + """Not actively used at this time. + The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER""" + def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] ) -> "RecordBasedMetadataTaskProperties": @@ -168,6 +250,12 @@ def fill_from_dict( The RecordBasedMetadataTaskProperties object. """ self.record_set_id = synapse_response.get("recordSetId", None) + self.suggested_authorization_mode = synapse_response.get( + "suggestedAuthorizationMode", None + ) + self.collaborator_principal_ids = synapse_response.get( + "collaboratorPrincipalIds", None + ) return self def to_synapse_request(self) -> Dict[str, Any]: @@ -177,9 +265,17 @@ def to_synapse_request(self) -> Dict[str, Any]: Returns: A dictionary representation of this object for API requests. """ - request_dict = {"concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES} - if self.record_set_id is not None: - request_dict["recordSetId"] = self.record_set_id + request_dict = { + "concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES, + "recordSetId": self.record_set_id, + "suggestedAuthorizationMode": ( + self.suggested_authorization_mode.value + if self.suggested_authorization_mode is not None + else None + ), + "collaboratorPrincipalIds": self.collaborator_principal_ids, + } + delete_none_keys(request_dict) return request_dict @@ -630,6 +726,15 @@ def create_grid_session( Always creates a new Grid session. To attach an existing session to a task, use set_active_grid_session instead. + The new session is created with the task's suggested_authorization_mode + (from task_properties), which the server uses to determine access: + + - SESSION_OWNER: access is limited to the session owner (owner_principal_id, + or the caller when not provided) and their team. + - SOURCE_BENEFACTOR: access is inherited from the benefactor of the source + entity (anyone with EDIT rights). + - Unset (legacy): the caller becomes the owner. + After the Grid is created, updates the CurationTaskStatus to point its active_session_id at the new session. If that update fails for any reason, the newly created Grid is deleted on a best-effort basis and the original @@ -1654,6 +1759,15 @@ async def create_grid_session_async( Always creates a new Grid session. To attach an existing session to a task, use set_active_grid_session_async instead. + The new session is created with the task's suggested_authorization_mode + (from task_properties), which the server uses to determine access: + + - SESSION_OWNER: access is limited to the session owner (owner_principal_id, + or the caller when not provided) and their team. + - SOURCE_BENEFACTOR: access is inherited from the benefactor of the source + entity (anyone with EDIT rights). + - Unset (legacy): the caller becomes the owner. + After the Grid is created, updates the CurationTaskStatus to point its active_session_id at the new session. If that update fails for any reason, the newly created Grid is deleted on a best-effort basis and the original @@ -1718,6 +1832,7 @@ async def main(): grid = Grid( record_set_id=self.task_properties.record_set_id, owner_principal_id=owner_principal_id, + authorization_mode=self.task_properties.suggested_authorization_mode, ) elif isinstance(self.task_properties, FileBasedMetadataTaskProperties): if not self.task_properties.file_view_id: @@ -1736,6 +1851,7 @@ async def main(): sql=f"SELECT * FROM {self.task_properties.file_view_id}" ), owner_principal_id=owner_principal_id, + authorization_mode=self.task_properties.suggested_authorization_mode, ) else: raise ValueError( @@ -1944,7 +2060,7 @@ async def main(): @dataclass -class CreateGridRequest(AsynchronousCommunicator): +class CreateGridRequest(EnumCoercionMixin, AsynchronousCommunicator): """ Start a job to create a new Grid session. @@ -1961,6 +2077,8 @@ class CreateGridRequest(AsynchronousCommunicator): In order to allow other users to access the grid, set this value to the id of a team. When a team ID is provided as the owner, all members of that team will have equal access to the grid. Note: If a team ID is provided, the creator of the grid must be a member of the team. + authorization_mode: Controls access permissions and row visibility at session + creation time. See AuthorizationMode. Defaults to SESSION_OWNER when omitted. session_id: The session ID of the created grid (populated from response) """ @@ -1983,9 +2101,15 @@ class CreateGridRequest(AsynchronousCommunicator): When a team ID is provided as the owner, all members of that team will have equal access to the grid. Note: If a team ID is provided, the creator of the grid must be a member of the team.""" + authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """Controls access permissions and row visibility at session creation time. + See AuthorizationMode. When omitted, the service defaults to SESSION_OWNER.""" + session_id: Optional[str] = None """The session ID of the created grid (populated from response)""" + _ENUM_FIELDS: ClassVar[dict[str, type]] = {"authorization_mode": AuthorizationMode} + _grid_session_data: Optional[Dict[str, Any]] = field(default=None, compare=False) """Internal storage of the full grid session data from the response for later use.""" @@ -2034,7 +2158,11 @@ def fill_grid_session_from_response(self, grid_session: "Grid") -> "Grid": grid_session.last_replica_id_service = data.get("lastReplicaIdService", None) grid_session.grid_json_schema_id = data.get("gridJsonSchema$Id", None) grid_session.source_entity_id = data.get("sourceEntityId", None) - grid_session.owner_principal_id = data.get("ownerPrincipalId") + owner_principal_id = data.get("ownerPrincipalId") + grid_session.owner_principal_id = ( + int(owner_principal_id) if owner_principal_id is not None else None + ) + grid_session.authorization_mode = data.get("authorizationMode", None) return grid_session @@ -2051,6 +2179,11 @@ def to_synapse_request(self) -> Dict[str, Any]: self.initial_query.to_synapse_request() if self.initial_query else None ) request_dict["ownerPrincipalId"] = self.owner_principal_id + request_dict["authorizationMode"] = ( + self.authorization_mode.value + if self.authorization_mode is not None + else None + ) delete_none_keys(request_dict) return request_dict @@ -2933,7 +3066,7 @@ def import_csv( @dataclass @async_to_sync -class Grid(GridSynchronousProtocol): +class Grid(EnumCoercionMixin, GridSynchronousProtocol): """ A GridSession provides functionality to create and manage grid sessions in Synapse. Grid sessions are used for curation workflows where data can be edited in a grid format @@ -2946,6 +3079,9 @@ class Grid(GridSynchronousProtocol): owner_principal_id: The principal ID (user or team) that will own the created grid session. When not provided, the principal ID of the caller is used. + authorization_mode: Controls access permissions and row visibility at + session creation time. See AuthorizationMode. When not provided, the + service default (SESSION_OWNER) is used. session_id: The unique sessionId that identifies the grid session started_by: The user that started this session started_on: The date-time when the session was started @@ -3014,6 +3150,11 @@ class Grid(GridSynchronousProtocol): """The principal ID (user or team) that will own the created grid session. When not provided, the principal ID of the caller is used.""" + authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """Controls access permissions and row visibility at session creation time. + See AuthorizationMode. When not provided, the service default (SESSION_OWNER) + is used.""" + session_id: Optional[str] = None """The unique sessionId that identifies the grid session""" @@ -3047,6 +3188,8 @@ class Grid(GridSynchronousProtocol): validation_summary_statistics: Optional[ValidationSummary] = None """Summary statistics for validation results""" + _ENUM_FIELDS: ClassVar[dict[str, type]] = {"authorization_mode": AuthorizationMode} + async def create_async( self, attach_to_previous_session=False, @@ -3132,6 +3275,7 @@ async def main(): record_set_id=self.record_set_id, initial_query=self.initial_query, owner_principal_id=self.owner_principal_id, + authorization_mode=self.authorization_mode, ) result = await create_request.send_job_and_wait_async( timeout=timeout, synapse_client=synapse_client @@ -3219,7 +3363,11 @@ def fill_from_dict(self, synapse_response: Dict[str, Any]) -> "Grid": ) self.grid_json_schema_id = synapse_response.get("gridJsonSchema$Id", None) self.source_entity_id = synapse_response.get("sourceEntityId", None) - self.owner_principal_id = synapse_response.get("ownerPrincipalId") + owner_principal_id = synapse_response.get("ownerPrincipalId") + self.owner_principal_id = ( + int(owner_principal_id) if owner_principal_id is not None else None + ) + self.authorization_mode = synapse_response.get("authorizationMode", None) return self @skip_async_to_sync diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index 9fd72cfa4..2599b12f7 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -12,6 +12,7 @@ from synapseclient.core.exceptions import SynapseHTTPError from synapseclient.core.utils import make_bogus_uuid_file from synapseclient.models import ( + AuthorizationMode, CurationTask, CurationTaskStatus, EntityView, @@ -130,6 +131,7 @@ async def test_store_file_based_curation_task_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) # AND a CurationTask @@ -152,6 +154,10 @@ async def test_store_file_based_curation_task_async( assert isinstance(stored_task.task_properties, FileBasedMetadataTaskProperties) assert stored_task.task_properties.upload_folder_id == folder.id assert stored_task.task_properties.file_view_id == entity_view.id + assert ( + stored_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SESSION_OWNER + ) assert stored_task.etag is not None assert stored_task.created_on is not None assert stored_task.created_by is not None @@ -163,6 +169,7 @@ async def test_store_record_based_curation_task_async( # AND a RecordBasedMetadataTaskProperties task_properties = RecordBasedMetadataTaskProperties( record_set_id=record_set.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) # AND a CurationTask @@ -186,6 +193,10 @@ async def test_store_record_based_curation_task_async( stored_task.task_properties, RecordBasedMetadataTaskProperties ) assert stored_task.task_properties.record_set_id == record_set.id + assert ( + stored_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SESSION_OWNER + ) assert stored_task.etag is not None assert stored_task.created_on is not None assert stored_task.created_by is not None @@ -259,6 +270,7 @@ async def test_get_curation_task_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, ) original_task = await CurationTask( data_type=data_type, @@ -282,6 +294,10 @@ async def test_get_curation_task_async( ) assert retrieved_task.task_properties.upload_folder_id == folder.id assert retrieved_task.task_properties.file_view_id == entity_view.id + assert ( + retrieved_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SOURCE_BENEFACTOR + ) assert retrieved_task.etag == original_task.etag assert retrieved_task.created_on == original_task.created_on assert retrieved_task.created_by == original_task.created_by @@ -735,6 +751,7 @@ async def test_create_grid_session_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) stored_task = await CurationTask( data_type=data_type, @@ -743,15 +760,22 @@ async def test_create_grid_session_async( task_properties=task_properties, ).store_async(synapse_client=syn) - # WHEN I create a grid session for the task asynchronously + # WHEN I create a grid session for the task asynchronously, owned by the + # current user + current_user_id = int(syn.getUserProfile()["ownerId"]) grid = await stored_task.create_grid_session_async( - timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=syn + owner_principal_id=current_user_id, + timeout=ASYNC_JOB_TIMEOUT_SEC, + synapse_client=syn, ) request.addfinalizer(lambda: grid.delete(synapse_client=syn)) - # THEN a Grid is returned with a populated session_id + # THEN a Grid is returned with a populated session_id owned by that user + # AND the grid carries the task's suggested authorization mode assert isinstance(grid, Grid) assert grid.session_id is not None + assert grid.owner_principal_id == current_user_id + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER # AND the curation task status now references the new grid session status = await stored_task.get_status_async(synapse_client=syn) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index 75b297333..cba114053 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -9,8 +9,10 @@ import pytest from synapseclient import Synapse +from synapseclient.core.async_utils import wrap_async_to_sync from synapseclient.core.utils import make_bogus_data_file from synapseclient.models import ( + AuthorizationMode, EntityView, File, Folder, @@ -139,6 +141,33 @@ async def test_create_and_list_grid_sessions_async( assert our_session.started_by == created_grid.started_by assert our_session.source_entity_id == record_set_fixture.id + async def test_create_grid_session_with_authorization_mode_async( + self, request: pytest.FixtureRequest, record_set_fixture: RecordSet + ) -> None: + # GIVEN: A Grid instance with a record_set_id and an explicit authorization mode + grid = Grid( + record_set_id=record_set_fixture.id, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN: Creating a grid session + created_grid = await grid.create_async( + timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn + ) + + # AND: The grid session is scheduled for cleanup + request.addfinalizer( + lambda: wrap_async_to_sync( + created_grid.delete_async(synapse_client=self.syn) + ) + ) + + # THEN: The server accepts the request and creates the session successfully + assert created_grid is grid + assert created_grid.session_id is not None + assert created_grid.source_entity_id == record_set_fixture.id + assert created_grid.authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR + async def test_create_grid_session_and_reuse_session_async( self, record_set_fixture: RecordSet ) -> None: diff --git a/tests/unit/synapseclient/extensions/unit_test_curator.py b/tests/unit/synapseclient/extensions/unit_test_curator.py index de0273b1c..7f9b6a96f 100644 --- a/tests/unit/synapseclient/extensions/unit_test_curator.py +++ b/tests/unit/synapseclient/extensions/unit_test_curator.py @@ -54,6 +54,7 @@ ) from synapseclient.models import Column, ColumnType, ViewTypeMask from synapseclient.models.curation import ( + AuthorizationMode, FileBasedMetadataTaskProperties, RecordBasedMetadataTaskProperties, ) @@ -406,7 +407,7 @@ def test_create_file_based_metadata_task_schema_retrieval_error( ) @patch("synapseclient.extensions.curator.file_based_metadata_task.Folder") @patch("synapseclient.extensions.curator.file_based_metadata_task.CurationTask") - def test_create_file_based_metadata_task_with_assignee( + def test_create_file_based_metadata_task_forwards_all_params_to_curation_task( self, mock_curation_task_cls, mock_folder_cls, @@ -414,15 +415,34 @@ def test_create_file_based_metadata_task_with_assignee( mock_get_client, mock_get_project_id_from_entity_id, ): - """Test successful creation of file-based metadata task with assignee_principal_id.""" - # Test both string and int inputs - int should be converted to string + """Every parameter is forwarded to CurationTask. The assignee int is coerced + to a string, and suggested_authorization_mode supplied as a string is coerced + to the AuthorizationMode enum by FileBasedMetadataTaskProperties.""" + # (assignee_input, expected_assignee, auth_mode_input, expected_auth_mode) test_cases = [ - ("1234", "1234"), - (1234, "1234"), + ( + "1234", + "1234", + AuthorizationMode.SOURCE_BENEFACTOR, + AuthorizationMode.SOURCE_BENEFACTOR, + ), + ( + 1234, + "1234", + "SESSION_OWNER", + AuthorizationMode.SESSION_OWNER, + ), ] - for input_assignee, expected_assignee in test_cases: - with self.subTest(input_assignee=input_assignee): + for ( + input_assignee, + expected_assignee, + input_auth_mode, + expected_auth_mode, + ) in test_cases: + with self.subTest( + input_assignee=input_assignee, input_auth_mode=input_auth_mode + ): # Reset mocks for each subtest mock_curation_task_cls.reset_mock() mock_folder_cls.reset_mock() @@ -430,7 +450,8 @@ def test_create_file_based_metadata_task_with_assignee( mock_get_client.reset_mock() mock_get_project_id_from_entity_id.reset_mock() - # GIVEN a file-based metadata task with assignee_principal_id + # GIVEN a file-based metadata task with an assignee and an + # authorization mode mock_get_client.return_value = self.mock_syn mock_create_entity_view.return_value = "test_entity_view_id" mock_get_project_id_from_entity_id.return_value = self.project_id @@ -440,18 +461,13 @@ def test_create_file_based_metadata_task_with_assignee( mock_folder.get.return_value = mock_folder mock_folder.parent_id = "syn11111111" - mock_project = Mock() - mock_project.concreteType = "org.sagebionetworks.repo.model.Project" - mock_project.id = "syn22222222" - self.mock_syn.get.return_value = mock_project - mock_task = Mock() mock_task.task_id = "task123" mock_curation_task = Mock() mock_curation_task.store.return_value = mock_task mock_curation_task_cls.return_value = mock_curation_task - # WHEN I create the file-based metadata task with assignee_principal_id + # WHEN I create the file-based metadata task result = create_file_based_metadata_task( folder_id=self.folder_id, curation_task_name=self.curation_task_name, @@ -461,10 +477,12 @@ def test_create_file_based_metadata_task_with_assignee( schema_uri=self.schema_uri, enable_derived_annotations=True, assignee_principal_id=input_assignee, + suggested_authorization_mode=input_auth_mode, synapse_client=self.mock_syn, ) - # THEN the CurationTask should be called with assignee_principal_id as string + # THEN CurationTask is constructed with every parameter, the assignee + # coerced to a string and the authorization mode coerced to the enum mock_curation_task_cls.assert_called_once_with( data_type=self.curation_task_name, project_id=self.project_id, @@ -472,10 +490,11 @@ def test_create_file_based_metadata_task_with_assignee( assignee_principal_id=expected_assignee, task_properties=FileBasedMetadataTaskProperties( upload_folder_id=self.folder_id, - file_view_id=mock_create_entity_view.return_value, + file_view_id="test_entity_view_id", + suggested_authorization_mode=expected_auth_mode, ), ) - # AND the task should be created successfully + # AND the task is created successfully assert result == ("test_entity_view_id", "task123") mock_create_entity_view.assert_called_once_with( syn=self.mock_syn, @@ -606,6 +625,124 @@ def test_create_record_based_metadata_task_success( "A Grid object will no longer be created by this function starting in v5.0.0." ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.project_id_from_entity_id" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.Synapse.get_client" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.extract_schema_properties_from_web" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.tempfile.NamedTemporaryFile" + ) + @patch("synapseclient.extensions.curator.record_based_metadata_task.RecordSet") + @patch("synapseclient.extensions.curator.record_based_metadata_task.CurationTask") + @patch("synapseclient.extensions.curator.record_based_metadata_task.Grid") + @patch("builtins.open") + def test_create_record_based_metadata_task_forwards_all_params_to_curation_task( + self, + mock_open, + mock_grid_cls, + mock_curation_task_cls, + mock_record_set_cls, + mock_temp_file, + mock_extract_schema, + mock_get_client, + mock_get_project_id_from_entity_id, + ): + """Every parameter is forwarded to CurationTask. The assignee int is coerced + to a string, and suggested_authorization_mode supplied as a string is coerced + to the AuthorizationMode enum by RecordBasedMetadataTaskProperties.""" + # (assignee_input, expected_assignee, auth_mode_input, expected_auth_mode) + test_cases = [ + ( + "1234", + "1234", + AuthorizationMode.SOURCE_BENEFACTOR, + AuthorizationMode.SOURCE_BENEFACTOR, + ), + ( + 1234, + "1234", + "SESSION_OWNER", + AuthorizationMode.SESSION_OWNER, + ), + ] + + for ( + input_assignee, + expected_assignee, + input_auth_mode, + expected_auth_mode, + ) in test_cases: + with self.subTest( + input_assignee=input_assignee, input_auth_mode=input_auth_mode + ): + # Reset mocks for each subtest + for mock_obj in ( + mock_grid_cls, + mock_curation_task_cls, + mock_record_set_cls, + mock_temp_file, + mock_extract_schema, + mock_get_client, + mock_get_project_id_from_entity_id, + ): + mock_obj.reset_mock() + + # GIVEN a record-based metadata task with an assignee and an + # authorization mode + mock_get_client.return_value = self.mock_syn + mock_get_project_id_from_entity_id.return_value = self.project_id + + mock_extract_schema.return_value = pd.DataFrame(columns=["specimenID"]) + + mock_temp = Mock() + mock_temp.name = "/tmp/test.csv" + mock_temp_file.return_value = mock_temp + + mock_record_set = Mock() + mock_record_set.id = "syn87654321" + mock_record_set_instance = Mock() + mock_record_set_instance.store.return_value = mock_record_set + mock_record_set_cls.return_value = mock_record_set_instance + + mock_task = Mock() + mock_task.task_id = "task123" + mock_curation_task = Mock() + mock_curation_task.store.return_value = mock_task + mock_curation_task_cls.return_value = mock_curation_task + + # WHEN I create the record-based metadata task + create_record_based_metadata_task( + folder_id=self.folder_id, + record_set_name=self.record_set_name, + record_set_description=self.record_set_description, + curation_task_name=self.curation_task_name, + upsert_keys=self.upsert_keys, + instructions=self.instructions, + schema_uri=self.schema_uri, + assignee_principal_id=input_assignee, + suggested_authorization_mode=input_auth_mode, + create_grid=False, + synapse_client=self.mock_syn, + ) + + # THEN CurationTask is constructed with every parameter, the assignee + # coerced to a string and the authorization mode coerced to the enum + mock_curation_task_cls.assert_called_once_with( + data_type=self.curation_task_name, + project_id=self.project_id, + instructions=self.instructions, + assignee_principal_id=expected_assignee, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id="syn87654321", + suggested_authorization_mode=expected_auth_mode, + ), + ) + @patch( "synapseclient.extensions.curator.record_based_metadata_task.project_id_from_entity_id" ) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index 99f3d49b4..1e5859964 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -15,6 +15,7 @@ ) from synapseclient.models import EntityView, RecordSet from synapseclient.models.curation import ( + AuthorizationMode, CreateGridRequest, CurationTask, CurationTaskStatus, @@ -109,7 +110,9 @@ def _get_grid_session_response(): "lastReplicaIdService": -5, "gridJsonSchema$Id": "my-schema-id", "sourceEntityId": SOURCE_ENTITY_ID, - "ownerPrincipalId": OWNER_PRINCIPAL_ID, + # The server returns ownerPrincipalId as a string; the client coerces to int. + "ownerPrincipalId": str(OWNER_PRINCIPAL_ID), + "authorizationMode": "SESSION_OWNER", } @@ -177,9 +180,44 @@ def test_to_synapse_request_none_values(self) -> None: # WHEN I convert it to a request dict request = props.to_synapse_request() - # THEN the request should only contain concreteType + # THEN the request should only contain concreteType (all None-valued fields, + # including suggested_authorization_mode, are dropped) assert request == {"concreteType": FILE_BASED_METADATA_TASK_PROPERTIES} + def test_fill_from_dict_authorization_fields(self) -> None: + # GIVEN a response dict including the authorization fields + response = { + "uploadFolderId": UPLOAD_FOLDER_ID, + "fileViewId": FILE_VIEW_ID, + "suggestedAuthorizationMode": "SOURCE_BENEFACTOR", + "collaboratorPrincipalIds": ["111", "222"], + } + + # WHEN I fill a FileBasedMetadataTaskProperties from the dict + props = FileBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the mode is coerced to the enum and collaborators pass through + assert props.suggested_authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR + assert isinstance(props.suggested_authorization_mode, AuthorizationMode) + assert props.collaborator_principal_ids == ["111", "222"] + + def test_to_synapse_request_authorization_fields(self) -> None: + # GIVEN properties with the authorization mode supplied as a plain string + props = FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, + file_view_id=FILE_VIEW_ID, + suggested_authorization_mode="SESSION_OWNER", + collaborator_principal_ids=["111"], + ) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the enum value is serialized as a string and collaborators pass through + assert request["suggestedAuthorizationMode"] == "SESSION_OWNER" + assert request["collaboratorPrincipalIds"] == ["111"] + class TestRecordBasedMetadataTaskProperties: """Tests for the RecordBasedMetadataTaskProperties dataclass.""" @@ -206,6 +244,38 @@ def test_to_synapse_request(self) -> None: assert request["concreteType"] == RECORD_BASED_METADATA_TASK_PROPERTIES assert request["recordSetId"] == RECORD_SET_ID + def test_fill_from_dict_authorization_fields(self) -> None: + # GIVEN a response dict including the authorization fields + response = { + "recordSetId": RECORD_SET_ID, + "suggestedAuthorizationMode": "SESSION_OWNER", + "collaboratorPrincipalIds": ["111"], + } + + # WHEN I fill a RecordBasedMetadataTaskProperties from the dict + props = RecordBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the mode is coerced to the enum and collaborators pass through + assert props.suggested_authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(props.suggested_authorization_mode, AuthorizationMode) + assert props.collaborator_principal_ids == ["111"] + + def test_to_synapse_request_authorization_fields(self) -> None: + # GIVEN properties with an AuthorizationMode enum value set + props = RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the enum value is serialized as a string and the absent collaborators + # are dropped by delete_none_keys + assert request["suggestedAuthorizationMode"] == "SOURCE_BENEFACTOR" + assert "collaboratorPrincipalIds" not in request + class TestCreateTaskPropertiesFromDict: """Tests for the _create_task_properties_from_dict factory function.""" @@ -1093,6 +1163,97 @@ async def test_create_grid_session_async_unsupported_task_properties( ): await task.create_grid_session_async(synapse_client=self.syn) + async def test_create_grid_session_async_passes_authorization_mode_record_based( + self, + ) -> None: + """A record-based task forwards its suggested_authorization_mode to the Grid.""" + # GIVEN a record-based task in SESSION_OWNER mode + task = CurationTask( + task_id=TASK_ID, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode="SESSION_OWNER", + ), + ) + + with ( + patch.object(RecordSet, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session without an explicit owner + await task.create_grid_session_async(synapse_client=self.syn) + + # THEN the Grid is constructed with the task's authorization mode and the + # owner is left to the caller (None) for the server to resolve + kwargs = mock_grid_cls.call_args.kwargs + assert kwargs["authorization_mode"] == AuthorizationMode.SESSION_OWNER + assert kwargs["owner_principal_id"] is None + + async def test_create_grid_session_async_passes_authorization_mode_file_based( + self, + ) -> None: + """A file-based task forwards its suggested_authorization_mode to the Grid.""" + # GIVEN a file-based task in SOURCE_BENEFACTOR mode + task = CurationTask( + task_id=TASK_ID, + task_properties=FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, + file_view_id=FILE_VIEW_ID, + suggested_authorization_mode="SOURCE_BENEFACTOR", + ), + ) + + with ( + patch.object(EntityView, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session + await task.create_grid_session_async(synapse_client=self.syn) + + # THEN the Grid is constructed with the task's authorization mode + assert ( + mock_grid_cls.call_args.kwargs["authorization_mode"] + == AuthorizationMode.SOURCE_BENEFACTOR + ) + + async def test_create_grid_session_async_passes_explicit_owner(self) -> None: + """An explicit owner_principal_id is passed straight through to the Grid.""" + # GIVEN a record-based task + task = CurationTask( + task_id=TASK_ID, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode="SESSION_OWNER", + ), + ) + + with ( + patch.object(RecordSet, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session with an explicit owner + await task.create_grid_session_async( + owner_principal_id=555, synapse_client=self.syn + ) + + # THEN that owner is forwarded to the Grid unchanged + assert mock_grid_cls.call_args.kwargs["owner_principal_id"] == 555 + async def test_list_async_assigned_to_me_and_assignee_ids_raises(self) -> None: # GIVEN both assigned_to_me and assignee_ids are provided # WHEN I call list_async @@ -1227,7 +1388,12 @@ def test_fill_from_dict(self) -> None: assert grid.last_replica_id_service == -5 assert grid.grid_json_schema_id == "my-schema-id" assert grid.source_entity_id == SOURCE_ENTITY_ID + # AND the owner principal id is coerced from the response string to an int assert grid.owner_principal_id == OWNER_PRINCIPAL_ID + assert isinstance(grid.owner_principal_id, int) + # AND the authorization mode is coerced from the string to the enum + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(grid.authorization_mode, AuthorizationMode) async def test_create_async_with_record_set_id(self) -> None: # GIVEN a Grid with a record_set_id @@ -1247,11 +1413,37 @@ async def test_create_async_with_record_set_id(self) -> None: ): result = await grid.create_async(synapse_client=self.syn) - # THEN the grid should be populated with session data + # THEN the grid should be populated with session data, including the + # authorization mode coerced from the response string to the enum assert result.session_id == SESSION_ID assert result.started_by == STARTED_BY assert result.started_on == STARTED_ON assert result.source_entity_id == SOURCE_ENTITY_ID + assert result.authorization_mode == AuthorizationMode.SESSION_OWNER + + async def test_create_async_forwards_authorization_mode_to_request(self) -> None: + # GIVEN a Grid with a record_set_id and an explicit authorization mode + grid = Grid( + record_set_id=RECORD_SET_ID, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN I call create_async (patching the CreateGridRequest the Grid builds) + with patch( + "synapseclient.models.curation.CreateGridRequest" + ) as mock_request_cls: + mock_request = mock_request_cls.return_value + mock_request.send_job_and_wait_async = AsyncMock(return_value=mock_request) + await grid.create_async(synapse_client=self.syn) + + # THEN the request is constructed once with the grid's authorization mode + # forwarded alongside the other session parameters + mock_request_cls.assert_called_once_with( + record_set_id=RECORD_SET_ID, + initial_query=None, + owner_principal_id=None, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) async def test_create_async_no_record_set_or_query_raises(self) -> None: # GIVEN a Grid with neither record_set_id nor initial_query @@ -1633,6 +1825,12 @@ def test_fill_grid_session_from_response(self) -> None: assert grid.started_by == STARTED_BY assert grid.etag == GRID_ETAG assert grid.source_entity_id == SOURCE_ENTITY_ID + # AND the owner principal id is coerced from the response string to an int + assert grid.owner_principal_id == OWNER_PRINCIPAL_ID + assert isinstance(grid.owner_principal_id, int) + # AND the authorization mode is coerced from the response string to the enum + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(grid.authorization_mode, AuthorizationMode) def test_to_synapse_request_with_record_set_id(self) -> None: # GIVEN a CreateGridRequest with a record_set_id @@ -1645,6 +1843,25 @@ def test_to_synapse_request_with_record_set_id(self) -> None: assert "concreteType" in result assert result["recordSetId"] == RECORD_SET_ID assert "initialQuery" not in result + # AND the absent authorization mode is dropped by delete_none_keys + assert "authorizationMode" not in result + + def test_to_synapse_request_with_authorization_mode(self) -> None: + # GIVEN a CreateGridRequest with the authorization mode supplied as a string + request = CreateGridRequest( + record_set_id=RECORD_SET_ID, + authorization_mode="SOURCE_BENEFACTOR", + ) + + # THEN the string is coerced to the enum on assignment by EnumCoercionMixin + assert request.authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR + assert isinstance(request.authorization_mode, AuthorizationMode) + + # WHEN I convert it to a synapse request + result = request.to_synapse_request() + + # THEN the enum value is serialized back to its string form + assert result["authorizationMode"] == "SOURCE_BENEFACTOR" class TestUploadToTablePreviewRequest: