diff --git a/docs/reference/experimental/async/form.md b/docs/reference/experimental/async/form.md new file mode 100644 index 000000000..da99f4a6a --- /dev/null +++ b/docs/reference/experimental/async/form.md @@ -0,0 +1,22 @@ +# FormGroup and Form + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.FormGroup + options: + inherited_members: true + members: + - create_async + + +::: synapseclient.models.FormData + options: + inherited_members: true + members: + - create_async + - list_async + - download_async diff --git a/docs/reference/experimental/mixins/form_data.md b/docs/reference/experimental/mixins/form_data.md new file mode 100644 index 000000000..f886be46c --- /dev/null +++ b/docs/reference/experimental/mixins/form_data.md @@ -0,0 +1 @@ +::: synapseclient.models.mixins.FormData diff --git a/docs/reference/experimental/mixins/form_group.md b/docs/reference/experimental/mixins/form_group.md new file mode 100644 index 000000000..dfcd5cb6d --- /dev/null +++ b/docs/reference/experimental/mixins/form_group.md @@ -0,0 +1 @@ +::: synapseclient.models.mixins.FormGroup diff --git a/docs/reference/experimental/sync/form.md b/docs/reference/experimental/sync/form.md new file mode 100644 index 000000000..8c9f8b37d --- /dev/null +++ b/docs/reference/experimental/sync/form.md @@ -0,0 +1,22 @@ +# FormGroup and Form + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.FormGroup + options: + inherited_members: true + members: + - create + + +::: synapseclient.models.FormData + options: + inherited_members: true + members: + - create + - list + - download diff --git a/mkdocs.yml b/mkdocs.yml index 062e78769..e3d2b0c1a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,7 @@ nav: - Functional Interfaces: reference/experimental/functional_interfaces.md - SchemaOrganization: reference/experimental/sync/schema_organization.md - JSONSchema: reference/experimental/sync/json_schema.md + - FormGroup and Form: reference/experimental/sync/form.md - Extensions: - Curator: reference/extensions/curator.md - Asynchronous: @@ -128,6 +129,7 @@ nav: - Link: reference/experimental/async/link_entity.md - SchemaOrganization: reference/experimental/async/schema_organization.md - JSONSchema: reference/experimental/async/json_schema.md + - FormGroup and Form: reference/experimental/async/form.md - Mixins: - AccessControllable: reference/experimental/mixins/access_controllable.md - StorableContainer: reference/experimental/mixins/storable_container.md @@ -135,6 +137,8 @@ nav: - FailureStrategy: reference/experimental/mixins/failure_strategy.md - BaseJSONSchema: reference/experimental/mixins/base_json_schema.md - ContainerEntityJSONSchema: reference/experimental/mixins/container_json_schema.md + - FormData: reference/experimental/mixins/form_data.md + - FormGroup: reference/experimental/mixins/form_group.md - Further Reading: - Home: explanations/home.md diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 09c578ed2..86f1a9947 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -87,6 +87,12 @@ put_file_multipart_add, put_file_multipart_complete, ) +from .form_services import ( + create_form_data, + create_form_group, + list_form_data, + list_form_data_sync, +) from .json_schema_services import ( bind_json_schema_to_entity, create_organization, @@ -282,4 +288,9 @@ "get_evaluation_acl", "update_evaluation_acl", "get_evaluation_permissions", + # form services + "create_form_group", + "create_form_data", + "list_form_data", + "list_form_data_sync", ] diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py new file mode 100644 index 000000000..5e9098390 --- /dev/null +++ b/synapseclient/api/form_services.py @@ -0,0 +1,171 @@ +import json +from typing import TYPE_CHECKING, Any, AsyncGenerator, Generator + +from synapseclient.api.api_client import rest_post_paginated_async +from synapseclient.core.async_utils import wrap_async_generator_to_sync_generator + +if TYPE_CHECKING: + from synapseclient import Synapse + from synapseclient.models.mixins.form import StateEnum + + +async def create_form_group( + synapse_client: "Synapse", + name: str, +) -> dict[str, Any]: + """ + + Create a form group asynchronously. + + Arguments: + synapse_client: The Synapse client to use for the request. + name: A globally unique name for the group. Required. Between 3 and 256 characters. + + Returns: + A Form group object as a dictionary. + Object matching + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + return await client.rest_post_async(uri=f"/form/group?name={name}", body={}) + + +async def create_form_data( + synapse_client: "Synapse", + group_id: str, + form_change_request: dict[str, Any], +) -> dict[str, Any]: + """ + + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + form_change_request: a dictionary of form change request matching . + + Returns: + A Form data object as a dictionary. + Object matching + + Note: The caller must have the SUBMIT permission on the FormGroup to create/update/submit FormData. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + return await client.rest_post_async( + uri=f"/form/data?groupId={group_id}", + body=json.dumps(form_change_request), + ) + + +async def list_form_data( + synapse_client: "Synapse", + group_id: str, + filter_by_state: list["StateEnum"], + as_reviewer: bool = False, +) -> AsyncGenerator[dict[str, Any], None]: + """ + List FormData objects and their associated status that match the filters of the provided request. + + When as_reviewer=False: + Returns FormData objects owned by the caller. Only objects owned by the caller will be returned. + + When as_reviewer=True: + Returns FormData objects for the entire group. This is used by service accounts to review submissions. + Requires READ_PRIVATE_SUBMISSION permission on the FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. Required. + filter_by_state: list of StateEnum values to filter the FormData objects. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint to list FormData for the entire group. + If False (default), lists only FormData owned by the caller. + + Yields: + A single page of FormData objects matching the request. + Object matching + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + body: dict[str, Any] = {"groupId": group_id, "filterByState": filter_by_state} + + if as_reviewer: + uri = "/form/data/list/reviewer" + else: + uri = "/form/data/list" + + async for item in rest_post_paginated_async( + uri=uri, + body=body, + synapse_client=client, + ): + yield item + + +def list_form_data_sync( + synapse_client: "Synapse", + group_id: str, + filter_by_state: list["StateEnum"], + as_reviewer: bool = False, +) -> Generator[dict[str, Any], None, None]: + """ + List FormData objects and their associated status that match the filters of the provided request. + + This is the synchronous version of list_form_data_async. + + When as_reviewer=False: + Returns FormData objects owned by the caller. Only objects owned by the caller will be returned. + + When as_reviewer=True: + Returns FormData objects for the entire group. This is used by service accounts to review submissions. + Requires READ_PRIVATE_SUBMISSION permission on the FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. Required. + filter_by_state: list of StateEnum values to filter the FormData objects. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint to list FormData for the entire group. + If False (default), lists only FormData owned by the caller. + + Yields: + A single page of FormData objects matching the request. + Object matching + """ + return wrap_async_generator_to_sync_generator( + list_form_data( + synapse_client=synapse_client, + group_id=group_id, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + ) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 9a5322727..084229b4c 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -18,6 +18,7 @@ from synapseclient.models.evaluation import Evaluation from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder +from synapseclient.models.form import FormData, FormGroup from synapseclient.models.link import Link from synapseclient.models.materializedview import MaterializedView from synapseclient.models.mixins.table_components import QueryMixin @@ -132,6 +133,9 @@ # JSON Schema models "SchemaOrganization", "JSONSchema", + # Form models + "FormGroup", + "FormData", ] # Static methods to expose as functions diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py new file mode 100644 index 000000000..456711728 --- /dev/null +++ b/synapseclient/models/form.py @@ -0,0 +1,434 @@ +import os +from dataclasses import dataclass +from typing import AsyncGenerator, Generator, List, Optional + +from synapseclient import Synapse +from synapseclient.core.async_utils import ( + async_to_sync, + skip_async_to_sync, + wrap_async_generator_to_sync_generator, +) +from synapseclient.models.mixins.form import FormChangeRequest +from synapseclient.models.mixins.form import FormData as FormDataMixin +from synapseclient.models.mixins.form import FormGroup as FormGroupMixin +from synapseclient.models.mixins.form import StateEnum +from synapseclient.models.protocols.form_protocol import ( + FormDataProtocol, + FormGroupProtocol, +) + + +@dataclass +@async_to_sync +class FormGroup(FormGroupMixin, FormGroupProtocol): + """Dataclass representing a FormGroup.""" + + async def create_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormGroup": + """ + Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. + + Arguments: + synapse_client: Optional Synapse client instance for authentication. + + Returns: + A FormGroup object containing the details of the created group. + + Examples: create a form group + + ```python + from synapseclient import Synapse + from synapseclient.models import FormGroup + import asyncio + + async def create_my_form_group(): + syn = Synapse() + syn.login() + + form_group = FormGroup(name="my_unique_form_group_name") + form_group = await form_group.create_async() + print(form_group) + + asyncio.run(create_my_form_group()) + ``` + """ + if not self.name: + raise ValueError("FormGroup 'name' must be provided to create a FormGroup.") + + from synapseclient.api.form_services import create_form_group + + response = await create_form_group( + synapse_client=synapse_client, + name=self.name, + ) + return self.fill_from_dict(response) + + +@dataclass +@async_to_sync +class FormData(FormDataMixin, FormDataProtocol): + """Dataclass representing a FormData.""" + + def _validate_filter_by_state( + self, + filter_by_state: List["StateEnum"], + as_reviewer: bool = False, + ) -> None: + """ + Validate filter_by_state values. + + Arguments: + filter_by_state: List of StateEnum values to validate. + as_reviewer: If True, uses the POST POST /form/data/list/reviewer endpoint to review submission. If False (default), use POST /form/data/list endpoint to list only FormData owned by the caller. + """ + # Define valid states based on as_reviewer + if as_reviewer: + valid_states = { + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + } + else: + valid_states = { + StateEnum.WAITING_FOR_SUBMISSION, + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + } + + # Check each state + for state in filter_by_state: + if not isinstance(state, StateEnum): + valid_values = ", ".join(s.value for s in valid_states) + raise ValueError( + f"Invalid state type. Expected StateEnum. Valid values are: {valid_values}" + ) + + if state not in valid_states: + valid_values = ", ".join(s.value for s in valid_states) + raise ValueError( + f"StateEnum.{state.value} is not allowed. Valid values are: {valid_values}" + ) + + async def create_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormData": + """ + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + + Returns: + A FormData object containing the details of the created form data. + + Examples: create a form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData, File + import asyncio + + async def create_my_form_data(): + syn = Synapse() + syn.login() + + file = await File(id="syn123", download_file=True).get_async() + file_handle_id = file.file_handle.id + + form_data = FormData( + group_id="123", + name="my_form_data_name", + data_file_handle_id=file_handle_id + ) + form_data = await form_data.create_async() + + print(f"Created FormData: {form_data.form_data_id}") + print(f"Name: {form_data.name}") + print(f"Group ID: {form_data.group_id}") + print(f"Created By: {form_data.created_by}") + print(f"Created On: {form_data.created_on}") + print(f"Data File Handle ID: {form_data.data_file_handle_id}") + + if form_data.submission_status: + print(f"Submission State: {form_data.submission_status.state.value}") + + asyncio.run(create_my_form_data()) + ``` + + """ + from synapseclient.api import create_form_data + + if not self.group_id or not self.name or not self.data_file_handle_id: + raise ValueError( + "Missing required fields: 'group_id', 'name', and 'data_file_handle_id' are required to create a FormData." + ) + + form_change_request = FormChangeRequest( + name=self.name, file_handle_id=self.data_file_handle_id + ).to_dict() + response = await create_form_data( + synapse_client=synapse_client, + group_id=self.group_id, + form_change_request=form_change_request, + ) + return self.fill_from_dict(response) + + @skip_async_to_sync + async def list_async( + self, + *, + filter_by_state: List["StateEnum"], + synapse_client: Optional["Synapse"] = None, + as_reviewer: bool = False, + ) -> AsyncGenerator["FormData", None]: + """ + List FormData objects in a FormGroup. + + Arguments: + filter_by_state: list of StateEnum to filter the results. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + synapse_client: The Synapse client to use for the request. + + as_reviewer: If True, uses the POST POST /form/data/list/reviewer endpoint to review submission. If False (default), use POST /form/data/list endpoint to list only FormData owned by the caller. + + Yields: + FormData objects matching the request. + + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. + + Examples: List your own form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + import asyncio + + async def list_my_form_data(): + syn = Synapse() + syn.login() + + async for form_data in FormData(group_id="123").list_async( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + + asyncio.run(list_my_form_data()) + ``` + + Examples: List all form data as a reviewer + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + import asyncio + + async def list_for_review(): + syn = Synapse() + syn.login() + + # List all submissions waiting for review (reviewer mode) + async for form_data in FormData(group_id="123").list_async( + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + + asyncio.run(list_for_review()) + ``` + """ + from synapseclient.api import list_form_data + + if not self.group_id: + raise ValueError("'group_id' must be provided to list FormData.") + + # Validate filter_by_state based on reviewer mode + self._validate_filter_by_state( + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + gen = list_form_data( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + async for item in gen: + yield FormData().fill_from_dict(item) + + def list( + self, + *, + filter_by_state: List["StateEnum"], + synapse_client: Optional["Synapse"] = None, + as_reviewer: bool = False, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup. + + Arguments: + filter_by_state: Optional list of StateEnum to filter the results. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint (requires READ_PRIVATE_SUBMISSION + permission). If False (default), lists only FormData owned by the caller. + synapse_client: The Synapse client to use for the request. + + Yields: + FormData objects matching the request. + + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. + + Examples: List your own form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + ``` + + Examples: List all form data as a reviewer + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + + syn = Synapse() + syn.login() + + # List all submissions waiting for review (reviewer mode) + for form_data in FormData(group_id="123").list( + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + ``` + """ + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_async, + synapse_client=synapse_client, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + async def download_async( + self, + synapse_id: str, + download_location: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> str: + """ + Download the data file associated with this FormData object. + + Arguments: + synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). + download_location: The directory where the file should be downloaded. + synapse_client: The Synapse client to use for the request. + + Returns: + The path to the downloaded file. + + Examples: Download form data file + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import File, FormData + + async def download_form_data(): + syn = Synapse() + syn.login() + + file = await File(id="syn123", download_file=True).get_async() + file_handle_id = file.file_handle.id + + path = await FormData(data_file_handle_id=file_handle_id).download_async(synapse_id="syn123") + + print(f"Downloaded to: {path}") + + + asyncio.run(download_form_data()) + ``` + """ + + from synapseclient.core.download.download_functions import ( + download_by_file_handle, + ensure_download_location_is_directory, + ) + + client = Synapse.get_client(synapse_client=synapse_client) + + if not self.data_file_handle_id: + raise ValueError("data_file_handle_id must be set to download the file.") + + if download_location: + download_dir = ensure_download_location_is_directory( + download_location=download_location + ) + else: + download_dir = client.cache.get_cache_dir( + file_handle_id=self.data_file_handle_id + ) + + filename = f"SYNAPSE_FORM_{self.data_file_handle_id}.csv" + + path = await download_by_file_handle( + file_handle_id=self.data_file_handle_id, + synapse_id=synapse_id, + entity_type="FileEntity", + destination=os.path.join(download_dir, filename), + synapse_client=client, + ) + return path diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 02da00ef2..62ddcf017 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -2,6 +2,13 @@ from synapseclient.models.mixins.access_control import AccessControllable from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator +from synapseclient.models.mixins.form import ( + FormChangeRequest, + FormData, + FormGroup, + FormSubmissionStatus, + StateEnum, +) from synapseclient.models.mixins.json_schema import ( BaseJSONSchema, CausingException, @@ -28,4 +35,9 @@ "JSONSchemaValidationStatistics", "ValidationException", "CausingException", + "FormGroup", + "FormData", + "FormChangeRequest", + "FormSubmissionStatus", + "StateEnum", ] diff --git a/synapseclient/models/mixins/form.py b/synapseclient/models/mixins/form.py new file mode 100644 index 000000000..1f6dbf87d --- /dev/null +++ b/synapseclient/models/mixins/form.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + + +@dataclass +class FormGroup: + """Dataclass representing a FormGroup.s""" + + group_id: Optional[str] = None + """Unique identifier provided by the system.""" + + name: Optional[str] = None + """Unique name for the group provided by the caller.""" + + created_by: Optional[str] = None + """Id of the user that created this group""" + + created_on: Optional[str] = None + """The date this object was originally created.""" + + def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormGroup": + """Converts a response from the REST API into this dataclass.""" + self.group_id = synapse_response.get("groupId", None) + self.name = synapse_response.get("name", None) + self.created_by = synapse_response.get("createdBy", None) + self.created_on = synapse_response.get("createdOn", None) + + return self + + +@dataclass +class FormChangeRequest: + """Dataclass representing a FormChangeRequest.""" + + name: Optional[str] = None + """The name of the form. Required for FormData create. Optional for FormData update. Between 3 and 256 characters""" + + file_handle_id: Optional[str] = None + """The fileHandleId for the data of the form.""" + + def to_dict(self) -> dict[str, Any]: + """Converts this dataclass into a dictionary for REST API requests.""" + request_dict: dict[str, Any] = {} + if self.name is not None: + request_dict["name"] = self.name + if self.file_handle_id is not None: + request_dict["fileHandleId"] = self.file_handle_id + return request_dict + + +class StateEnum(str, Enum): + """ + The enumeration of possible FormData submission states. + """ + + WAITING_FOR_SUBMISSION = "WAITING_FOR_SUBMISSION" + """Indicates that the FormData is waiting for the creator to submit it.""" + + SUBMITTED_WAITING_FOR_REVIEW = "SUBMITTED_WAITING_FOR_REVIEW" + """Indicates the FormData has been submitted and is now awaiting review.""" + + ACCEPTED = "ACCEPTED" + """The submitted FormData has been reviewed and accepted.""" + + REJECTED = "REJECTED" + """The submitted FormData has been reviewed but was not accepted. See the rejection message for more details.""" + + +@dataclass +class FormSubmissionStatus: + """ + The status of a submitted FormData object. + """ + + submitted_on: Optional[str] = None + """The date when the object was submitted.""" + + reviewed_on: Optional[str] = None + """The date when this submission was reviewed.""" + + reviewed_by: Optional[str] = None + """The id of the service user that reviewed the submission.""" + + state: Optional[StateEnum] = None + """The enumeration of possible FormData submission states.""" + + rejection_message: Optional[str] = None + """The message provided by the reviewer when a submission is rejected.""" + + def fill_from_dict( + self, synapse_response: dict[str, Any] + ) -> "FormSubmissionStatus": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response dictionary from the Synapse REST API. + + Returns: + This FormSubmissionStatus object with populated fields. + """ + self.submitted_on = synapse_response.get("submittedOn", None) + self.reviewed_on = synapse_response.get("reviewedOn", None) + self.reviewed_by = synapse_response.get("reviewedBy", None) + + # Handle enum conversion + self.state = ( + StateEnum(synapse_response.get("state", None)) + if synapse_response.get("state", None) + else None + ) + self.rejection_message = synapse_response.get("rejectionMessage", None) + + return self + + +@dataclass +class FormData: + """ + Represents a FormData object in Synapse. + """ + + form_data_id: Optional[str] = None + """The system issued identifier that uniquely identifies this object.""" + + etag: Optional[str] = None + """Will change whenever there is a change to this data or its status.""" + + group_id: Optional[str] = None + """The identifier of the group that manages this data. Required.""" + + name: Optional[str] = None + """User provided name for this submission. Required.""" + + created_by: Optional[str] = None + """Id of the user that created this object.""" + + created_on: Optional[str] = None + """The date this object was originally created.""" + + modified_on: Optional[str] = None + """The date this object was last modified.""" + + data_file_handle_id: Optional[str] = None + """The identifier of the data FileHandle for this object.""" + + submission_status: Optional[FormSubmissionStatus] = None + """The status of a submitted FormData object.""" + + def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormData": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response dictionary from the Synapse REST API. + + Returns: + This FormData object with populated fields. + """ + self.form_data_id = synapse_response.get("formDataId", None) + self.etag = synapse_response.get("etag", None) + self.group_id = synapse_response.get("groupId", None) + self.name = synapse_response.get("name", None) + self.created_by = synapse_response.get("createdBy", None) + self.created_on = synapse_response.get("createdOn", None) + self.modified_on = synapse_response.get("modifiedOn", None) + self.data_file_handle_id = synapse_response.get("dataFileHandleId", None) + + if ( + "submissionStatus" in synapse_response + and synapse_response["submissionStatus"] is not None + ): + self.submission_status = FormSubmissionStatus().fill_from_dict( + synapse_response["submissionStatus"] + ) + + return self diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py new file mode 100644 index 000000000..7e2b5a0e9 --- /dev/null +++ b/synapseclient/models/protocols/form_protocol.py @@ -0,0 +1,216 @@ +from typing import TYPE_CHECKING, Generator, List, Optional, Protocol + +if TYPE_CHECKING: + from synapseclient import Synapse + from synapseclient.models.mixins import ( + FormGroup, + FormData, + ) + from synapseclient.models.mixins.form import StateEnum + +from synapseclient.core.async_utils import wrap_async_generator_to_sync_generator + + +class FormGroupProtocol(Protocol): + """Protocol for FormGroup operations.""" + + def create( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormGroup": + """ + Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. + + Arguments: + synapse_client: Optional Synapse client instance for authentication. + + Returns: + A FormGroup object containing the details of the created group. + + Examples: create a form group + + ```python + from synapseclient import Synapse + from synapseclient.models import FormGroup + + syn = Synapse() + syn.login() + + form_group = FormGroup(name="my_unique_form_group_name") + form_group = form_group.create() + print(form_group) + ``` + """ + return FormGroup() + + +class FormDataProtocol(Protocol): + """Protocol for FormData operations.""" + + def create( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormData": + """ + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + + Returns: + A FormData object containing the details of the created form data. + + Note: + The `name` attribute must be set on the FormData instance before calling `create()`. + + Examples: create a form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData, File + + syn = Synapse() + syn.login() + + file = File(id="syn123", download_file=True).get() + file_handle_id = file.file_handle.id + + form_data = FormData( + group_id="123", + name="my_form_data_name", + data_file_handle_id=file_handle_id + ) + form_data = form_data.create() + + print(f"Created FormData: {form_data.form_data_id}") + print(f"Name: {form_data.name}") + print(f"Group ID: {form_data.group_id}") + print(f"Created By: {form_data.created_by}") + print(f"Created On: {form_data.created_on}") + print(f"Data File Handle ID: {form_data.data_file_handle_id}") + + if form_data.submission_status: + print(f"Submission State: {form_data.submission_status.state.value}") + ``` + """ + return FormData() + + def list( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + as_reviewer: bool = False, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint (requires READ_PRIVATE_SUBMISSION + permission). If False (default), lists only FormData owned by the caller. + + Yields: + FormData objects matching the request. + + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. + + Examples: List your own form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + ``` + + Examples: List all form data as a reviewer + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + + syn = Synapse() + syn.login() + + # List all submissions waiting for review (reviewer mode) + for form_data in FormData(group_id="123").list( + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + ``` + """ + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_async, + synapse_client=synapse_client, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + def download( + self, + synapse_id: str, + download_location: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> str: + """ + Download the data file associated with this FormData object. + + Arguments: + synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). + download_location: The directory where the file should be downloaded. + synapse_client: The Synapse client to use for the request. + + Returns: + The path to the downloaded file. + + Examples: Download form data file + + ```python + from synapseclient import Synapse + from synapseclient.models import File, FormData + + syn = Synapse() + syn.login() + + file = File(id="syn123", download_file=True).get() + file_handle_id = file.file_handle.id + + path = FormData(data_file_handle_id=file_handle_id).download(synapse_id="syn123") + + print(f"Downloaded to: {path}") + ``` + """ + return str() diff --git a/tests/integration/synapseclient/models/async/test_form_async.py b/tests/integration/synapseclient/models/async/test_form_async.py new file mode 100644 index 000000000..05d3a5370 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_form_async.py @@ -0,0 +1,233 @@ +""" +Integration tests for the synapseclient.models.Form class. +""" +import tempfile +import uuid +from typing import Callable + +import pytest + +import synapseclient.core.utils as utils +from synapseclient import Synapse +from synapseclient.models import File, FormData, FormGroup, Project +from synapseclient.models.mixins.form import StateEnum + + +class TestFormGroup: + async def test_create_form_group( + self, syn, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test creating a form group.""" + unique_name = str(uuid.uuid4()) + form_group = await FormGroup(name=unique_name).create_async(synapse_client=syn) + + assert form_group is not None + assert form_group.group_id is not None + assert form_group.name == unique_name + + schedule_for_cleanup(form_group.group_id) + + async def test_raise_error_on_missing_name(self, syn) -> None: + """Test that creating a form group without a name raises an error.""" + form_group = FormGroup() + + with pytest.raises(ValueError) as e: + await form_group.create_async(synapse_client=syn) + assert ( + str(e.value) == "FormGroup 'name' must be provided to create a FormGroup." + ) + + +class TestFormData: + @pytest.fixture(autouse=True, scope="session") + async def test_form_group( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> FormGroup: + """Create a test form group for use in form data tests.""" + unique_name = "test_form_group_" + str(uuid.uuid4()) + form_group = FormGroup(name=unique_name) + form_group = await form_group.create_async(synapse_client=syn) + + schedule_for_cleanup(form_group.group_id) + + return form_group + + @pytest.fixture(autouse=True, scope="session") + async def test_file( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> File: + """Create a test file for use in form data tests.""" + # Create a test project and a test file to get a file handle ID + project_name = str(uuid.uuid4()) + project = Project(name=project_name) + project = await project.store_async(synapse_client=syn) + + file_path = utils.make_bogus_data_file() + file = await File(path=file_path, parent_id=project.id).store_async( + synapse_client=syn + ) + + schedule_for_cleanup(file.id) + schedule_for_cleanup(file_path) + schedule_for_cleanup(project.id) + + return file + + async def test_create_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test creating form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + + assert form_data is not None + assert form_data.form_data_id is not None + assert form_data.name == unique_name + assert form_data.group_id == test_form_group.group_id + assert form_data.data_file_handle_id == test_file.file_handle.id + assert form_data.submission_status.state.value == "WAITING_FOR_SUBMISSION" + + schedule_for_cleanup(form_data.form_data_id) + + async def test_create_raise_error_on_missing_fields(self, syn: Synapse) -> None: + """Test that creating form data without required fields raises an error.""" + form_data = FormData() + + with pytest.raises(ValueError) as e: + await form_data.create_async(synapse_client=syn) + assert ( + str(e.value) + == "Missing required fields: 'group_id', 'name', and 'data_file_handle_id' are required to create a FormData." + ) + + async def test_list_form_data_reviewer_false( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data owned by the caller + retrieved_ids = [] + async for form_data in FormData(group_id=test_form_group.group_id).list_async( + synapse_client=syn, + filter_by_state=[StateEnum.WAITING_FOR_SUBMISSION], + as_reviewer=False, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + async def test_list_form_data_reviewer_true( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data as reviewer.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + # Submit the form data + syn.restPOST(uri=f"/form/data/{form_data.form_data_id}/submit", body={}) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data as reviewer + retrieved_ids = [] + async for form_data in FormData(group_id=test_form_group.group_id).list_async( + synapse_client=syn, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW], + as_reviewer=True, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + async def test_download_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + + schedule_for_cleanup(form_data.form_data_id) + + downloaded_form_path = await FormData( + data_file_handle_id=test_file.file_handle.id + ).download_async(synapse_client=syn, synapse_id=form_data.form_data_id) + + schedule_for_cleanup(downloaded_form_path) + + assert test_file.file_handle.id in downloaded_form_path + + async def test_download_form_data_with_directory( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data to a specific directory.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + tmp_dir = tempfile.mkdtemp() + schedule_for_cleanup(tmp_dir) + + downloaded_form_path = await FormData( + data_file_handle_id=test_file.file_handle.id + ).download_async( + synapse_client=syn, + synapse_id=form_data.form_data_id, + download_location=tmp_dir, + ) + + schedule_for_cleanup(form_data.form_data_id) + + assert test_file.file_handle.id in downloaded_form_path + assert str(downloaded_form_path).startswith(str(tmp_dir)) diff --git a/tests/integration/synapseclient/models/synchronous/test_form.py b/tests/integration/synapseclient/models/synchronous/test_form.py new file mode 100644 index 000000000..9a7ff1067 --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_form.py @@ -0,0 +1,231 @@ +""" +Integration tests for the synapseclient.models.Form class. +""" +import tempfile +import uuid +from typing import Callable + +import pytest + +import synapseclient.core.utils as utils +from synapseclient import Synapse +from synapseclient.models import File, FormData, FormGroup, Project +from synapseclient.models.mixins.form import StateEnum + + +class TestFormGroup: + def test_create_form_group( + self, syn, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test creating a form group.""" + unique_name = str(uuid.uuid4()) + form_group = FormGroup(name=unique_name).create(synapse_client=syn) + + assert form_group is not None + assert form_group.group_id is not None + assert form_group.name == unique_name + + schedule_for_cleanup(form_group.group_id) + + def test_raise_error_on_missing_name(self, syn) -> None: + """Test that creating a form group without a name raises an error.""" + form_group = FormGroup() + + with pytest.raises(ValueError) as e: + form_group.create(synapse_client=syn) + assert ( + str(e.value) == "FormGroup 'name' must be provided to create a FormGroup." + ) + + +class TestFormData: + @pytest.fixture(autouse=True, scope="session") + def test_form_group( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> FormGroup: + """Create a test form group for use in form data tests.""" + unique_name = "test_form_group_" + str(uuid.uuid4()) + form_group = FormGroup(name=unique_name) + form_group = form_group.create(synapse_client=syn) + + schedule_for_cleanup(form_group.group_id) + + return form_group + + @pytest.fixture(autouse=True, scope="session") + def test_file( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> File: + """Create a test file for use in form data tests.""" + # Create a test project and a test file to get a file handle ID + project_name = str(uuid.uuid4()) + project = Project(name=project_name) + project = project.store(synapse_client=syn) + + file_path = utils.make_bogus_data_file() + file = File(path=file_path, parent_id=project.id).store(synapse_client=syn) + + schedule_for_cleanup(file.id) + schedule_for_cleanup(file_path) + schedule_for_cleanup(project.id) + + return file + + def test_create_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test creating form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + + assert form_data is not None + assert form_data.form_data_id is not None + assert form_data.name == unique_name + assert form_data.group_id == test_form_group.group_id + assert form_data.data_file_handle_id == test_file.file_handle.id + assert form_data.submission_status.state.value == "WAITING_FOR_SUBMISSION" + + schedule_for_cleanup(form_data.form_data_id) + + def test_create_raise_error_on_missing_fields(self, syn: Synapse) -> None: + """Test that creating form data without required fields raises an error.""" + form_data = FormData() + + with pytest.raises(ValueError) as e: + form_data.create(synapse_client=syn) + assert ( + str(e.value) + == "Missing required fields: 'group_id', 'name', and 'data_file_handle_id' are required to create a FormData." + ) + + def test_list_form_data_reviewer_false( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data owned by the caller + retrieved_ids = [] + for form_data in FormData(group_id=test_form_group.group_id).list( + synapse_client=syn, + filter_by_state=[StateEnum.WAITING_FOR_SUBMISSION], + as_reviewer=False, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + def test_list_form_data_reviewer_true( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data as reviewer.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + # Submit the form data + syn.restPOST(uri=f"/form/data/{form_data.form_data_id}/submit", body={}) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data as reviewer + retrieved_ids = [] + for form_data in FormData(group_id=test_form_group.group_id).list( + synapse_client=syn, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW], + as_reviewer=True, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + def test_download_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + + schedule_for_cleanup(form_data.form_data_id) + + downloaded_form_path = FormData( + data_file_handle_id=test_file.file_handle.id + ).download(synapse_client=syn, synapse_id=form_data.form_data_id) + + schedule_for_cleanup(downloaded_form_path) + + assert test_file.file_handle.id in downloaded_form_path + + def test_download_form_data_with_directory( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data to a specific directory.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + tmp_dir = tempfile.mkdtemp() + schedule_for_cleanup(tmp_dir) + + downloaded_form_path = FormData( + data_file_handle_id=test_file.file_handle.id + ).download( + synapse_client=syn, + synapse_id=form_data.form_data_id, + download_location=tmp_dir, + ) + + schedule_for_cleanup(form_data.form_data_id) + + assert test_file.file_handle.id in downloaded_form_path + assert str(downloaded_form_path).startswith(str(tmp_dir)) diff --git a/tests/unit/synapseclient/mixins/unit_test_form_mixin.py b/tests/unit/synapseclient/mixins/unit_test_form_mixin.py new file mode 100644 index 000000000..192ce9b42 --- /dev/null +++ b/tests/unit/synapseclient/mixins/unit_test_form_mixin.py @@ -0,0 +1,160 @@ +from synapseclient.models.mixins import ( + FormChangeRequest, + FormData, + FormGroup, + FormSubmissionStatus, + StateEnum, +) + + +class TestFormGroupMixin: + def test_fill_from_dict_with_valid_data(self) -> None: + """Test fill_from_dict with all fields populated""" + response_dict = { + "groupId": "12345", + "name": "Test Form Group", + "createdBy": "67890", + "createdOn": "2024-01-01T00:00:00.000Z", + } + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id == "12345" + assert properties.name == "Test Form Group" + assert properties.created_by == "67890" + assert properties.created_on == "2024-01-01T00:00:00.000Z" + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with some missing fields""" + response_dict = { + "groupId": "12345", + # 'name' is missing + "createdBy": "67890", + # 'createdOn' is missing + } + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id == "12345" + assert properties.name is None + assert properties.created_by == "67890" + assert properties.created_on is None + + def test_fill_from_dict_empty_dict(self) -> None: + """Test fill_from_dict with an empty dictionary""" + response_dict = {} + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id is None + assert properties.name is None + assert properties.created_by is None + assert properties.created_on is None + + +class TestFormChangeRequest: + """Unit tests for FormChangeRequest.to_dict()""" + + def test_to_dict_with_all_fields(self): + """Test to_dict with all fields populated""" + # GIVEN a FormChangeRequest with all fields + form_request = FormChangeRequest(name="my_form_name", file_handle_id="123456") + + # WHEN converting to dict + result = form_request.to_dict() + + # THEN all fields should be present + assert result == {"name": "my_form_name", "fileHandleId": "123456"} + + +class TestFormSubmissionStatus: + """Unit tests for SubmissionStatus dataclass""" + + def test_form_submission_status_initialization(self) -> None: + """Test initialization of FormSubmissionStatus with all fields""" + status = FormSubmissionStatus( + submitted_on="2024-01-01T00:00:00.000Z", + reviewed_on="2024-01-02T00:00:00.000Z", + reviewed_by="user_123", + ) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert status.reviewed_by == "user_123" + + def test_fill_from_dict(self) -> None: + """Test fill_from_dict method of FormSubmissionStatus""" + response_dict = { + "submittedOn": "2024-01-01T00:00:00.000Z", + "reviewedOn": "2024-01-02T00:00:00.000Z", + "reviewedBy": "user_123", + } + + status = FormSubmissionStatus().fill_from_dict(response_dict) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert status.reviewed_by == "user_123" + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with missing fields""" + response_dict = { + "submittedOn": "2024-01-01T00:00:00.000Z" + # 'reviewedOn' and 'reviewedBy' are missing + } + + status = FormSubmissionStatus().fill_from_dict(response_dict) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on is None + assert status.reviewed_by is None + + +class TestFormDataMixin: + def test_fill_from_dict_with_valid_data(self) -> None: + """Test fill_from_dict with all fields populated""" + response_dict = { + "formDataId": "54321", + "groupId": "12345", + "name": "Test Form Data", + "dataFileHandleId": "67890", + "submissionStatus": { + "submittedOn": "2024-01-01T00:00:00.000Z", + "reviewedOn": "2024-01-02T00:00:00.000Z", + "reviewedBy": "user_123", + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "rejectionMessage": None, + }, + } + + form_data = FormData().fill_from_dict(response_dict) + + assert form_data.form_data_id == "54321" + assert form_data.group_id == "12345" + assert form_data.name == "Test Form Data" + assert form_data.data_file_handle_id == "67890" + assert form_data.submission_status.submitted_on == "2024-01-01T00:00:00.000Z" + assert form_data.submission_status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert form_data.submission_status.reviewed_by == "user_123" + assert ( + form_data.submission_status.state == StateEnum.SUBMITTED_WAITING_FOR_REVIEW + ) + assert form_data.submission_status.rejection_message is None + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with some missing fields""" + response_dict = { + "formDataId": "54321", + # 'groupId' is missing + "name": "Test Form Data", + # 'dataFileHandleId' is missing + # 'submissionStatus' is missing + } + + form_data = FormData().fill_from_dict(response_dict) + + assert form_data.form_data_id == "54321" + assert form_data.group_id is None + assert form_data.name == "Test Form Data" + assert form_data.data_file_handle_id is None + assert form_data.submission_status is None diff --git a/tests/unit/synapseclient/models/async/unit_test_form_async.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py new file mode 100644 index 000000000..cd33690c0 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -0,0 +1,291 @@ +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient.models import FormData, FormGroup +from synapseclient.models.mixins import StateEnum + + +class TestFormGroup: + """Unit tests for the FormGroup model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_group_async""" + return { + "groupId": "12345", + "name": "my_test_form_group", + "createdOn": "2023-12-01T10:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T10:00:00.000Z", + } + + async def test_create_async_success(self, syn, mock_response): + """Test successful form group creation""" + # GIVEN a FormGroup with a name + form_group = FormGroup(name="my_test_form_group") + + # WHEN creating the form group + with patch( + "synapseclient.api.form_services.create_form_group", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_create: + result = await form_group.create_async(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create.assert_called_once_with( + synapse_client=syn, + name="my_test_form_group", + ) + + # AND the result should be a FormGroup with populated fields + assert isinstance(result, FormGroup) + assert result.name == "my_test_form_group" + assert result.group_id == "12345" + assert result.created_by == "3350396" + assert result.created_on == "2023-12-01T10:00:00.000Z" + + async def test_create_async_without_name_raises_error(self, syn): + """Test that creating without a name raises ValueError""" + # GIVEN a FormGroup without a name + form_group = FormGroup() + + # WHEN creating the form group + # THEN it should raise ValueError + with pytest.raises(ValueError, match="FormGroup 'name' must be provided"): + await form_group.create_async(synapse_client=syn) + + +class TestFormData: + """Unit tests for the FormData model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_data""" + return { + "formDataId": "67890", + "groupId": "12345", + "name": "my_test_form_data", + "dataFileHandleId": "54321", + "createdOn": "2023-12-01T11:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T11:00:00.000Z", + "submissionStatus": { + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "submittedOn": "2023-12-01T11:05:00.000Z", + "reviewedBy": None, + "reviewedOn": None, + "rejectionReason": None, + }, + } + + async def test_create_async_success(self, syn, mock_response): + """Test successful form data creation""" + # GIVEN a FormData with required fields + form_data = FormData( + group_id="12345", + name="my_test_form_data", + data_file_handle_id="54321", + ) + + # WHEN creating the form data + with patch( + "synapseclient.api.create_form_data", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_create_form: + result = await form_data.create_async(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + form_change_request={ + "name": "my_test_form_data", + "fileHandleId": "54321", + }, + ) + + # AND the result should be a FormData with populated fields + assert isinstance(result, FormData) + assert result.name == "my_test_form_data" + assert result.form_data_id == "67890" + assert result.group_id == "12345" + assert result.data_file_handle_id == "54321" + assert result.created_by == "3350396" + assert ( + result.submission_status.state.value == "SUBMITTED_WAITING_FOR_REVIEW" + ) + + async def test_create_async_without_required_fields_raises_error(self, syn): + """Test that creating without required fields raises ValueError""" + # GIVEN a FormData missing required fields + form_data = FormData(name="incomplete_form_data") + + # WHEN creating the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, + match="'group_id', 'name', and 'data_file_handle_id' are required", + ): + await form_data.create_async(synapse_client=syn) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state", + [ + # Test for non-reviewers - allow all possible state filters + ( + False, + [ + StateEnum.WAITING_FOR_SUBMISSION, + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for reviewers - only allow review-related state filters + ( + True, + [ + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for non-reviewers - only allow selected state filters + (False, [StateEnum.ACCEPTED, StateEnum.REJECTED]), + # Test for reviewers - only allow selected state filters + (True, [StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.REJECTED]), + ], + ) + async def test_list_async(self, syn, as_reviewer, filter_by_state): + """Test listing form data asynchronously""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + async def mock_form_data_list(): + yield { + "formDataId": "11111", + "groupId": "12345", + "name": "form_data_1", + "dataFileHandleId": "fh_1", + } + yield { + "formDataId": "22222", + "groupId": "12345", + "name": "form_data_2", + "dataFileHandleId": "fh_2", + } + yield { + "formDataId": "33333", + "groupId": "12345", + "name": "form_data_3", + "dataFileHandleId": "fh_3", + } + + async def mock_generator(): + async for item in mock_form_data_list(): + yield item + + # WHEN listing the form data + with patch( + "synapseclient.api.list_form_data", + return_value=mock_generator(), + ) as mock_list_form: + results = [] + + async for item in form_data.list_async( + synapse_client=syn, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ): + results.append(item) + + # THEN the results should be a list of FormData objects + assert len(results) == 3 + + assert all(isinstance(item, FormData) for item in results) + assert results[0].form_data_id == "11111" + assert results[1].form_data_id == "22222" + assert results[2].form_data_id == "33333" + + # THEN the API should be called with correct parameters + mock_list_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state, expected", + [ + # Test for non-reviewers - WAITING_FOR_SUBMISSION is allowed + (False, [StateEnum.WAITING_FOR_SUBMISSION, StateEnum.ACCEPTED], None), + # Test for reviewers - invalid state filter + (True, [StateEnum.WAITING_FOR_SUBMISSION], ValueError), + ], + ) + async def test_validate_filter_by_state_raises_error_for_invalid_states( + self, as_reviewer, filter_by_state, expected + ): + """Test that invalid state filters raise ValueError""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + # WHEN validating filter_by_state with invalid states for non-reviewer + # THEN it should raise ValueError + if expected is ValueError: + with pytest.raises(ValueError): + # Call the private method directly for testing + form_data._validate_filter_by_state( + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + async def test_download_async(self, syn): + """Test downloading form data asynchronously""" + # GIVEN a FormData with a form_data_id + form_data = FormData(form_data_id="67890", data_file_handle_id="54321") + + # WHEN downloading the form data + with patch( + "synapseclient.core.download.download_functions.download_by_file_handle", + new_callable=AsyncMock, + ) as mock_download_file_handle, patch.object(syn, "cache") as mock_cache, patch( + "synapseclient.core.download.download_functions.ensure_download_location_is_directory", + ) as mock_ensure_dir: + mock_cache.get.side_effect = "/tmp/foo" + mock_ensure_dir.return_value = ( + mock_cache.get_cache_dir.return_value + ) = "/tmp/download" + mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" + + await form_data.download_async( + synapse_client=syn, synapse_id="mock synapse_id" + ) + + # THEN the API should be called with correct parameters + mock_download_file_handle.assert_called_once_with( + file_handle_id=form_data.data_file_handle_id, + synapse_id="mock synapse_id", + entity_type="FileEntity", + destination=os.path.join(mock_ensure_dir.return_value, mock_file_name), + synapse_client=syn, + ) + + async def test_download_async_without_data_file_handle_id_raises_error(self, syn): + """Test that downloading without data_file_handle_id raises ValueError""" + # GIVEN a FormData without data_file_handle_id + form_data = FormData(form_data_id="67890") + + # WHEN downloading the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="data_file_handle_id must be set to download the file." + ): + await form_data.download_async( + synapse_client=syn, synapse_id="mock synapse_id" + ) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_form.py b/tests/unit/synapseclient/models/synchronous/unit_test_form.py new file mode 100644 index 000000000..cd13a350c --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_form.py @@ -0,0 +1,284 @@ +import os +from unittest.mock import patch + +import pytest + +from synapseclient.models import FormData, FormGroup +from synapseclient.models.mixins import StateEnum + + +class TestFormGroup: + """Unit tests for the FormGroup model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_group""" + return { + "groupId": "12345", + "name": "my_test_form_group", + "createdOn": "2023-12-01T10:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T10:00:00.000Z", + } + + def test_create_success(self, syn, mock_response): + """Test successful form group creation""" + # GIVEN a FormGroup with a name + form_group = FormGroup(name="my_test_form_group") + + # WHEN creating the form group + with patch( + "synapseclient.api.form_services.create_form_group", + return_value=mock_response, + ) as mock_create: + result = form_group.create(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create.assert_called_once_with( + synapse_client=syn, + name="my_test_form_group", + ) + + # AND the result should be a FormGroup with populated fields + assert isinstance(result, FormGroup) + assert result.name == "my_test_form_group" + assert result.group_id == "12345" + assert result.created_by == "3350396" + assert result.created_on == "2023-12-01T10:00:00.000Z" + + def test_create_without_name_raises_error(self, syn): + """Test that creating without a name raises ValueError""" + # GIVEN a FormGroup without a name + form_group = FormGroup() + + # WHEN creating the form group + # THEN it should raise ValueError + with pytest.raises(ValueError, match="FormGroup 'name' must be provided"): + form_group.create(synapse_client=syn) + + +class TestFormData: + """Unit tests for the FormData model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_data""" + return { + "formDataId": "67890", + "groupId": "12345", + "name": "my_test_form_data", + "dataFileHandleId": "54321", + "createdOn": "2023-12-01T11:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T11:00:00.000Z", + "submissionStatus": { + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "submittedOn": "2023-12-01T11:05:00.000Z", + "reviewedBy": None, + "reviewedOn": None, + "rejectionReason": None, + }, + } + + def test_create_success(self, syn, mock_response): + """Test successful form data creation""" + # GIVEN a FormData with required fields + form_data = FormData( + group_id="12345", + name="my_test_form_data", + data_file_handle_id="54321", + ) + + # WHEN creating the form data + with patch( + "synapseclient.api.create_form_data", + return_value=mock_response, + ) as mock_create_form: + result = form_data.create(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + form_change_request={ + "name": "my_test_form_data", + "fileHandleId": "54321", + }, + ) + + # AND the result should be a FormData with populated fields + assert isinstance(result, FormData) + assert result.name == "my_test_form_data" + assert result.form_data_id == "67890" + assert result.group_id == "12345" + assert result.data_file_handle_id == "54321" + assert result.created_by == "3350396" + assert ( + result.submission_status.state.value == "SUBMITTED_WAITING_FOR_REVIEW" + ) + + def test_create_without_required_fields_raises_error(self, syn): + """Test that creating without required fields raises ValueError""" + # GIVEN a FormData missing required fields + form_data = FormData(name="incomplete_form_data") + + # WHEN creating the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, + match="'group_id', 'name', and 'data_file_handle_id' are required", + ): + form_data.create(synapse_client=syn) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state", + [ + # Test for non-reviewers - allow all possible state filters + ( + False, + [ + StateEnum.WAITING_FOR_SUBMISSION, + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for reviewers - only allow review-related state filters + ( + True, + [ + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for non-reviewers - only allow selected state filters + (False, [StateEnum.ACCEPTED, StateEnum.REJECTED]), + # Test for reviewers - only allow selected state filters + (True, [StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.REJECTED]), + ], + ) + def test_list(self, syn, as_reviewer, filter_by_state): + """Test listing form data""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + async def mock_form_data_list(): + yield { + "formDataId": "11111", + "groupId": "12345", + "name": "form_data_1", + "dataFileHandleId": "fh_1", + } + yield { + "formDataId": "22222", + "groupId": "12345", + "name": "form_data_2", + "dataFileHandleId": "fh_2", + } + yield { + "formDataId": "33333", + "groupId": "12345", + "name": "form_data_3", + "dataFileHandleId": "fh_3", + } + + async def mock_generator(): + async for item in mock_form_data_list(): + yield item + + # WHEN listing the form data + with patch( + "synapseclient.api.list_form_data", + return_value=mock_generator(), + ) as mock_list_form: + results = [] + + for item in form_data.list( + synapse_client=syn, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ): + results.append(item) + + # THEN the results should be a list of FormData objects + assert len(results) == 3 + + assert all(isinstance(item, FormData) for item in results) + assert results[0].form_data_id == "11111" + assert results[1].form_data_id == "22222" + assert results[2].form_data_id == "33333" + + # THEN the API should be called with correct parameters + mock_list_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state, expected", + [ + # Test for non-reviewers - WAITING_FOR_SUBMISSION is allowed + (False, [StateEnum.WAITING_FOR_SUBMISSION, StateEnum.ACCEPTED], None), + # Test for reviewers - invalid state filter + (True, [StateEnum.WAITING_FOR_SUBMISSION], ValueError), + ], + ) + def test_validate_filter_by_state_raises_error_for_invalid_states( + self, as_reviewer, filter_by_state, expected + ): + """Test that invalid state filters raise ValueError""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + # WHEN validating filter_by_state with invalid states for non-reviewer + # THEN it should raise ValueError + if expected is ValueError: + with pytest.raises(ValueError): + # Call the private method directly for testing + form_data._validate_filter_by_state( + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + def test_download(self, syn): + """Test downloading form data""" + # GIVEN a FormData with a form_data_id + form_data = FormData(form_data_id="67890", data_file_handle_id="54321") + + # WHEN downloading the form data + with patch( + "synapseclient.core.download.download_functions.download_by_file_handle", + ) as mock_download_file_handle, patch.object(syn, "cache") as mock_cache, patch( + "synapseclient.core.download.download_functions.ensure_download_location_is_directory", + ) as mock_ensure_dir: + mock_cache.get.side_effect = "/tmp/foo" + mock_ensure_dir.return_value = ( + mock_cache.get_cache_dir.return_value + ) = "/tmp/download" + mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" + + form_data.download(synapse_client=syn, synapse_id="mock synapse_id") + + # THEN the API should be called with correct parameters + mock_download_file_handle.assert_called_once_with( + file_handle_id=form_data.data_file_handle_id, + synapse_id="mock synapse_id", + entity_type="FileEntity", + destination=os.path.join(mock_ensure_dir.return_value, mock_file_name), + synapse_client=syn, + ) + + def test_download_without_data_file_handle_id_raises_error(self, syn): + """Test that downloading without data_file_handle_id raises ValueError""" + # GIVEN a FormData without a data_file_handle_id + form_data = FormData(form_data_id="67890") + + # WHEN downloading the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="data_file_handle_id must be set to download the file." + ): + form_data.download(synapse_client=syn, synapse_id="mock synapse_id")