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..0f0705b56 --- /dev/null +++ b/synapseclient/api/form_services.py @@ -0,0 +1,173 @@ +import json +from typing import TYPE_CHECKING, Any, AsyncGenerator, Generator, Optional + +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: Optional[list["StateEnum"]] = None, + 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: Optional 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 + If None, returns all FormData objects. + + 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: Optional[list["StateEnum"]] = None, + 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: Optional 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 + If None, returns all FormData objects. + + 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..25770837c --- /dev/null +++ b/synapseclient/models/form.py @@ -0,0 +1,432 @@ +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): + 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): + def _validate_filter_by_state( + self, + filter_by_state: Optional[List["StateEnum"]] = None, + allow_waiting_submission: bool = True, + ) -> None: + """ + Validate filter_by_state values. + + Arguments: + filter_by_state: List of StateEnum values to validate. + allow_waiting_submission: If False, raises error if WAITING_FOR_SUBMISSION is present. + + Raises: + ValueError: If filter_by_state is empty or contains invalid values. + """ + if not filter_by_state: + raise ValueError("filter_by_state cannot be None or empty.") + + # Define valid states based on whether WAITING_FOR_SUBMISSION is allowed + valid_states = { + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + } + if allow_waiting_submission: + valid_states.add(StateEnum.WAITING_FOR_SUBMISSION) + + # 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( + "'group_id', 'name', and 'data_file_handle_id' must be provided 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, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + as_reviewer: bool = False, + ) -> AsyncGenerator["FormData", 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 + 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 + if as_reviewer: + allow_waiting_submission = False + else: + allow_waiting_submission = True + + self._validate_filter_by_state( + filter_by_state=filter_by_state, + allow_waiting_submission=allow_waiting_submission, + ) + + 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, + *, + 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, + ) + + 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 + async def download_form_data(): + syn = Synapse() + syn.login() + + form_data = await FormData(form_data_id="123").get_async() + path = await form_data.download_async( + synapse_id="syn12345678", + download_location="/tmp" + ) + 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..c4bd5b9f3 --- /dev/null +++ b/synapseclient/models/mixins/form.py @@ -0,0 +1,174 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + + +@dataclass +class FormGroup: + 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: + 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..02ddd2af9 --- /dev/null +++ b/synapseclient/models/protocols/form_protocol.py @@ -0,0 +1,214 @@ +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: + name: A globally unique name for the group. Required. Between 3 and 256 characters. + 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. + + 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: + download_location: The directory where the file should be downloaded. + synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). + 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 FormData + + syn = Synapse() + syn.login() + + form_data = FormData(form_data_id="123").get() # First get the FormData + path = form_data.download( # Then download + synapse_id="syn12345678", + download_location="/tmp" + ) + print(f"Downloaded to: {path}") + ``` + """ + return str() diff --git a/tests/unit/synapseclient/mixins/unit_test_form.py b/tests/unit/synapseclient/mixins/unit_test_form.py new file mode 100644 index 000000000..192ce9b42 --- /dev/null +++ b/tests/unit/synapseclient/mixins/unit_test_form.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..fda6513c6 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -0,0 +1,293 @@ +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' must be provided", + ): + 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 - empty filter + (False, [], ValueError), + # Test for reviewers - empty filter + (True, [], ValueError), + # 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, + allow_waiting_submission=not 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_form_data_id_raises_error(self, syn): + """Test that downloading without form_data_id raises ValueError""" + # GIVEN a FormData without a form_data_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..08a4a1845 --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_form.py @@ -0,0 +1,287 @@ +import os +from unittest.mock import patch + +import pytest + +from synapseclient import Synapse +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' must be provided", + ): + 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 - empty filter + (False, [], ValueError), + # Test for reviewers - empty filter + (True, [], ValueError), + # 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, + allow_waiting_submission=not 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_form_data_id_raises_error(self, syn): + """Test that downloading without form_data_id raises ValueError""" + # GIVEN a FormData without a form_data_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")