diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bfe5e401..328a336c2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,16 @@ History All release highlights of this project will be documented in this file. +4.4.38 - August 20, 2025 +________________________ + +**Updated** + + - ``SAClient.create_project`` now supports template uploads for Multimodal projects with the addition of a new ``form`` parameter. + - ``SAClient.upload_video_to_project`` should install ``ffmpeg-python`` manually for the function. + - ``SAClient.upload_videos_from_folder_to_project`` should install ``ffmpeg-python`` manually for the function. + + 4.4.37 - July 18, 2025 ______________________ diff --git a/requirements.txt b/requirements.txt index 03143bcb7..f5c9c033d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,11 +5,10 @@ opencv-python-headless~=4.7 packaging~=24.0 plotly~=5.14 pandas~=2.0 -ffmpeg-python~=0.2 pillow>=9.5,~=10.0 tqdm~=4.66 requests==2.* aiofiles==23.* fire==0.4.0 mixpanel==4.8.3 -superannotate-schemas==1.0.49 +superannotate-schemas==1.0.49 \ No newline at end of file diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 2390b15c4..2959deb09 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.37" +__version__ = "4.4.38" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 9bc8a103d..c3512dff1 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1131,35 +1131,40 @@ def create_project( workflows: Any = None, instructions_link: str = None, workflow: str = None, + form: dict = None, ): - """Create a new project in the team. + """Creates a new project in the team. For Multimodal projects, you must provide a valid form object, + which serves as a template determining the layout and behavior of the project's interface. - :param project_name: the new project's name + :param project_name: The new project's name. :type project_name: str - :param project_description: the new project's description + :param project_description: The new project's description. :type project_description: str - :param project_type: the new project type, Vector, Pixel, Video, Document, Tiled, PointCloud, Multimodal. + :param project_type: The project type. Supported types: 'Vector', 'Pixel', 'Video', 'Document', 'Tiled', 'PointCloud', 'Multimodal'. :type project_type: str :param settings: list of settings objects :type settings: list of dicts - :param classes: list of class objects + :param classes: List of class objects. Not allowed for 'Multimodal' projects. :type classes: list of dicts - :param workflows: Deprecated + :param workflows: Deprecated. Do not use. :type workflows: list of dicts - :param workflow: the name of the workflow already created within the team, which must match exactly. - If None, the default “System workflow” workflow will be set. + :param workflow: Name of the workflow already created within the team (must match exactly). If None, the default "System workflow" will be used. :type workflow: str - :param instructions_link: str of instructions URL + :param instructions_link: URL for project instructions. :type instructions_link: str - :return: dict object metadata the new project + :param form: Required for Multimodal projects. Must be a JSON object that conforms to SuperAnnotate’s schema + for Multimodal form templates, as used in the Multimodal Form Editor. + :type form: dict + + :return: Metadata of the newly created project. :rtype: dict """ if workflows is not None: @@ -1172,6 +1177,16 @@ def create_project( settings = parse_obj_as(List[SettingEntity], settings) else: settings = [] + if ProjectType(project_type) == ProjectType.MULTIMODAL: + if not form: + raise AppException( + "A form object is required when creating a Multimodal project." + ) + if classes is not None: + raise AppException( + "Classes cannot be provided for Multimodal projects." + ) + settings.append(SettingEntity(attribute="TemplateState", value=1)) if classes: classes = parse_obj_as(List[AnnotationClassEntity], classes) project_entity = entities.ProjectEntity( @@ -1194,6 +1209,13 @@ def create_project( project_response = self.controller.projects.create(project_entity) project_response.raise_for_status() project = project_response.data + if form: + form_response = self.controller.projects.attach_form(project, form) + try: + form_response.raise_for_status() + except AppException: + self.controller.projects.delete(project) + raise if classes: classes_response = self.controller.annotation_classes.create_multiple( project, classes @@ -2392,6 +2414,9 @@ def upload_videos_from_folder_to_project( """Uploads image frames from all videos with given extensions from folder_path to the project. Sets status of all the uploaded images to set_status if it is not None. + .. note:: + Only works on Image projects. + :param project: project name or folder path (e.g., "project1/folder1") :type project: str @@ -2486,6 +2511,9 @@ def upload_video_to_project( """Uploads image frames from video to platform. Uploaded images will have names "_.jpg". + .. note:: + Only works on Image projects. + :param project: project name or folder path (e.g., "project1/folder1") :type project: str diff --git a/src/superannotate/lib/core/entities/__init__.py b/src/superannotate/lib/core/entities/__init__.py index c4666973d..12adc442f 100644 --- a/src/superannotate/lib/core/entities/__init__.py +++ b/src/superannotate/lib/core/entities/__init__.py @@ -12,6 +12,8 @@ from lib.core.entities.items import PROJECT_ITEM_ENTITY_MAP from lib.core.entities.items import TiledEntity from lib.core.entities.items import VideoEntity +from lib.core.entities.multimodal_form import FormModel +from lib.core.entities.multimodal_form import generate_classes_from_form from lib.core.entities.project import AttachmentEntity from lib.core.entities.project import ContributorEntity from lib.core.entities.project import CustomFieldEntity @@ -55,4 +57,7 @@ "UserEntity", "IntegrationEntity", "PROJECT_ITEM_ENTITY_MAP", + # multimodal + "FormModel", + "generate_classes_from_form", ] diff --git a/src/superannotate/lib/core/entities/multimodal_form.py b/src/superannotate/lib/core/entities/multimodal_form.py new file mode 100644 index 000000000..79cf7077e --- /dev/null +++ b/src/superannotate/lib/core/entities/multimodal_form.py @@ -0,0 +1,369 @@ +""" +Multimodal form validator and class generator for SuperAnnotate projects. + +This module provides form validation and automatic class generation for multimodal projects. +It processes form components and generates corresponding annotation classes with proper +attribute groups and default values. + +Components that generate classes: + Text group: + - audio, avatar, datetime, image, input, code, csv, markdown, + paragraph, pdf, textarea, time, url, video, web-component + + Number group: + - number, rating, slider, voting + + Single select group: + - radio, select (when isMultiselect=False) + + Multiple select group: + - checkbox, select (when isMultiselect=True) + +Components that do not generate classes: + - button, container, group, divider, grid, tabs + +Usage: + form_model = FormModel(components=form_data['components']) + classes = form_model.generate_classes() + + # Or use the convenience function + classes = generate_classes_from_form(form_json) +""" +import random +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union + +from pydantic import BaseModel + + +class BaseComponent(BaseModel): + id: str + type: str + label: Optional[str] = None + isRequired: Optional[bool] = False + exclude: Optional[bool] = False + + +class BaseClassComponent(BaseComponent): + def generate_class(self): + raise NotImplementedError() + + @staticmethod + def _get_random_color(): + """Get random color for class""" + colors = [ + "#2D1C8F", + "#F4E1C2", + "#AC40F2", + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + ] + return random.choice(colors) + + +class SelectComponent(BaseClassComponent): + options: List[Dict[str, Any]] = [] + isMultiselect: Optional[bool] = False + isDynamic: Optional[bool] = False + + def generate_class(self): + """Generate class for select component""" + # If isDynamic is True, treat as text component + if self.isDynamic: + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + {"name": f"value_{self.id}", "group_type": "text", "attributes": []} + ], + } + + # Normal select behavior + attributes = [] + default_values = [] + for option in self.options: + # Check multiple possible fields for default state + is_default = ( + option.get("checked", False) + or option.get("selected", False) + or option.get("default", False) + or option.get("value") == option.get("defaultValue") + ) + default_value = 1 if is_default else 0 + + option_name = option.get("value", "") or option.get("label", "") + attributes.append( + {"name": option_name, "count": 0, "default": default_value} + ) + + if is_default: + if self.isMultiselect: + default_values.append(option_name) + else: + default_values = option_name + + group_type = "checklist" if self.isMultiselect else "radio" + final_default = ( + default_values + if self.isMultiselect + else (default_values if default_values else "") + ) + + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + { + "name": f"value_{self.id}", + "group_type": group_type, + "attributes": attributes, + "default_value": final_default, + } + ], + } + + +class NumberComponent(BaseClassComponent): + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = 1 + + def generate_class(self): + """Generate class for number component""" + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + {"name": f"value_{self.id}", "group_type": "numeric", "attributes": []} + ], + } + + +class TextComponent(BaseClassComponent): + placeholder: Optional[str] = None + min: Optional[int] = None + max: Optional[int] = None + + def generate_class(self): + """Generate class for text component""" + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + {"name": f"value_{self.id}", "group_type": "text", "attributes": []} + ], + } + + +class RadioComponent(BaseClassComponent): + options: List[Dict[str, Any]] = [] + + def generate_class(self): + """Generate class for radio component""" + attributes = [] + default_value = "" + + for option in self.options: + # Check multiple possible fields for default state + is_default = ( + option.get("checked", False) + or option.get("selected", False) + or option.get("default", False) + or option.get("value") == option.get("defaultValue") + ) + default_val = 1 if is_default else 0 + + option_name = option.get("value", "") or option.get("label", "") + attributes.append({"name": option_name, "count": 0, "default": default_val}) + + if is_default: + default_value = option_name + + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + { + "name": f"value_{self.id}", + "group_type": "radio", + "attributes": attributes, + "default_value": default_value or "", + } + ], + } + + +class CheckboxComponent(BaseClassComponent): + options: List[Dict[str, Any]] = [] + + def generate_class(self): + """Generate class for checkbox component""" + attributes = [] + default_values = [] + + for option in self.options: + # Check multiple possible fields for default state + is_default = ( + option.get("checked", False) + or option.get("selected", False) + or option.get("default", False) + or option.get("value") == option.get("defaultValue") + ) + default_val = 1 if is_default else 0 + + option_name = option.get("value", "") or option.get("label", "") + attributes.append({"name": option_name, "count": 0, "default": default_val}) + + if is_default: + default_values.append(option_name) + + return { + "name": self.id, + "color": self._get_random_color(), + "count": 0, + "type": 1, + "attribute_groups": [ + { + "name": f"value_{self.id}", + "group_type": "checklist", + "attributes": attributes, + "default_value": default_values, + } + ], + } + + +class FormModel(BaseModel): + components: List[Dict[str, Any]] + code: Optional[Union[str, List]] = "" + environments: List[Any] = [] + + @property + def code_as_string(self) -> str: + """Convert code to string if it's a list""" + if isinstance(self.code, list): + return "\n".join(str(item) for item in self.code) + return self.code or "" + + def _extract_all_components( + self, components: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Recursively extract all components including nested ones""" + all_components = [] + + for component in components: + # Add current component + all_components.append(component) + + # Check for children and recursively extract them + if "children" in component: + children = component["children"] + if isinstance(children, list): + all_components.extend(self._extract_all_components(children)) + elif isinstance(children, dict): + # Handle case where children might be a single component + all_components.extend(self._extract_all_components([children])) + + return all_components + + def generate_classes(self) -> List[Dict[str, Any]]: + """Generate classes from form components""" + classes = [] + + # Component type mappings + TEXT_COMPONENTS = { + "audio", + "avatar", + "dateTime", + "image", + "input", + "code", + "csv", + "markdown", + "paragraph", + "pdf", + "textarea", + "time", + "url", + "video", + "web-component", + "webComponent", + "pdfComponent", + } + + NUMBER_COMPONENTS = {"number", "rating", "slider", "voting"} + + EXCLUDED_COMPONENTS = { + "button", + "container", + "group", + "divider", + "grid", + "tabs", + } + + # Extract all components recursively + all_components = self._extract_all_components(self.components) + + for component_data in all_components: + component_type = component_data.get("type") + + # Skip excluded components + if component_type in EXCLUDED_COMPONENTS: + continue + + # Skip components marked as exclude=True + if component_data.get("exclude", False): + continue + + # Create appropriate component instance + if component_type == "select": + component = SelectComponent(**component_data) + elif component_type == "radio": + component = RadioComponent(**component_data) + elif component_type == "checkbox": + component = CheckboxComponent(**component_data) + elif component_type in NUMBER_COMPONENTS: + component = NumberComponent(**component_data) + elif component_type in TEXT_COMPONENTS: + component = TextComponent(**component_data) + else: + # Skip unknown component types + continue + + # Generate class if component should generate one + if hasattr(component, "generate_class"): + class_def = component.generate_class() + classes.append(class_def) + + return classes + + +def generate_classes_from_form(form_json: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Generate classes JSON from form components. + + Args: + form_json: Dictionary containing form components + + Returns: + List of class definitions with attribute groups + """ + form_model = FormModel(**form_json) + return form_model.generate_classes() diff --git a/src/superannotate/lib/core/plugin.py b/src/superannotate/lib/core/plugin.py index 662e78417..e0d52bef5 100644 --- a/src/superannotate/lib/core/plugin.py +++ b/src/superannotate/lib/core/plugin.py @@ -7,7 +7,6 @@ from typing import Union import cv2 -import ffmpeg from lib.core.exceptions import ImageProcessingException from PIL import Image from PIL import ImageDraw @@ -197,6 +196,8 @@ def get_video_rotate_code(video_path, log): 270: cv2.ROTATE_90_COUNTERCLOCKWISE, } try: + import ffmpeg + meta_dict = ffmpeg.probe(str(video_path)) rot = int(meta_dict["streams"][0]["tags"]["rotate"]) if rot: @@ -206,6 +207,8 @@ def get_video_rotate_code(video_path, log): rot, ) return cv2_rotations[rot] + except ImportError: + raise Exception("Install ffmpeg-python~=0.2 to use this function.") except Exception as e: warning_str = "" if "ffprobe" in str(e): diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 3877c5e56..1e3ff5880 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -7,7 +7,10 @@ import lib.core as constants from lib.core.conditions import Condition from lib.core.conditions import CONDITION_EQ as EQ +from lib.core.entities import AnnotationClassEntity from lib.core.entities import ContributorEntity +from lib.core.entities import FormModel +from lib.core.entities import generate_classes_from_form from lib.core.entities import ProjectEntity from lib.core.entities import SettingEntity from lib.core.entities import TeamEntity @@ -21,6 +24,8 @@ from lib.core.serviceproviders import BaseServiceProvider from lib.core.usecases.base import BaseUseCase from lib.core.usecases.base import BaseUserBasedUseCase +from pydantic import ValidationError + logger = logging.getLogger("sa") @@ -984,3 +989,51 @@ def execute(self) -> Response: self._response.data = [] return self._response + + +class AttachFormUseCase(BaseUseCase): + def __init__( + self, + team: TeamEntity, + project: ProjectEntity, + service_provider: BaseServiceProvider, + form: dict, + ): + super().__init__() + self._team = team + self._project = project + self._service_provider = service_provider + self._form = form + self._classes = None + self._entity = None + + def validate_form(self): + if self._form is None: + raise AppException("Form was not provided.") + try: + self._entity = FormModel(**self._form) + except ValidationError: + self._service_provider.projects.delete(self._project) + raise AppException( + "The provided form object is invalid or does not match the required schema." + ) + + def execute(self) -> Response: + if self.is_valid(): + classes_to_create = generate_classes_from_form(self._form) + classes = [AnnotationClassEntity.parse_obj(c) for c in classes_to_create] + self._service_provider.projects.attach_editor_template( + self._team, self._project, template=self._form + ) + response = self._service_provider.annotation_classes.create_multiple( + self._project, classes + ) + if response.ok: + self._response.data = response.data + else: + self._response.errors = response.error + logger.error( + "Failed to attach form to the project. Project will be deleted." + ) + self._service_provider.projects.delete(self._project) + return self._response diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 967d4f2cf..ed0b18a3a 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -531,6 +531,15 @@ def create(self, entity: ProjectEntity) -> Response: ) return use_case.execute() + def attach_form(self, project: ProjectEntity, form: dict): + use_case = usecases.AttachFormUseCase( + team=self._team, + project=project, + form=form, + service_provider=self.service_provider, + ) + return use_case.execute() + def list(self, condition: Condition): use_case = usecases.GetProjectsUseCase( condition=condition, @@ -587,12 +596,11 @@ def add_contributors( project: ProjectEntity, contributors: List[ContributorEntity], ): - project = self.get_metadata(project).data + project = self.get_metadata(project, include_contributors=True).data for contributor in contributors: contributor.user_role = self.service_provider.get_role_name( project, contributor.user_role ) - project = self.get_metadata(project).data use_case = usecases.AddContributorsToProject( team=team, project=project, diff --git a/tests/data_set/multimodal_form/expected_classes.json b/tests/data_set/multimodal_form/expected_classes.json new file mode 100644 index 000000000..60bfd2d46 --- /dev/null +++ b/tests/data_set/multimodal_form/expected_classes.json @@ -0,0 +1,600 @@ +[ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677515, + "project_id": 1114489, + "type": "object", + "name": "r_c77v29", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520529, + "group_type": "text", + "class_id": 5677515, + "name": "value_r_c77v29", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677516, + "project_id": 1114489, + "type": "object", + "name": "r_q3j6wn", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520530, + "group_type": "text", + "class_id": 5677516, + "name": "value_r_q3j6wn", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677517, + "project_id": 1114489, + "type": "object", + "name": "r_66b5q3", + "color": "#96CEB4", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520531, + "group_type": "checklist", + "class_id": 5677517, + "name": "value_r_66b5q3", + "attributes": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263860, + "group_id": 5520531, + "project_id": 1114489, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263861, + "group_id": 5520531, + "project_id": 1114489, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263862, + "group_id": 5520531, + "project_id": 1114489, + "name": "Option 3", + "default": 0 + } + ], + "default_value": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677518, + "project_id": 1114489, + "type": "object", + "name": "r_923kb6", + "color": "#4ECDC4", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520532, + "group_type": "radio", + "class_id": 5677518, + "name": "value_r_923kb6", + "attributes": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263863, + "group_id": 5520532, + "project_id": 1114489, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263864, + "group_id": 5520532, + "project_id": 1114489, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263865, + "group_id": 5520532, + "project_id": 1114489, + "name": "Option 3", + "default": 0 + } + ], + "default_value": null + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677519, + "project_id": 1114489, + "type": "object", + "name": "r_88fb2z", + "color": "#45B7D1", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520533, + "group_type": "text", + "class_id": 5677519, + "name": "value_r_88fb2z", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677520, + "project_id": 1114489, + "type": "object", + "name": "r_tz83ki", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520534, + "group_type": "text", + "class_id": 5677520, + "name": "value_r_tz83ki", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677521, + "project_id": 1114489, + "type": "object", + "name": "r_e7a4za", + "color": "#AC40F2", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520535, + "group_type": "numeric", + "class_id": 5677521, + "name": "value_r_e7a4za", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677522, + "project_id": 1114489, + "type": "object", + "name": "r_8kwi9s", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520536, + "group_type": "text", + "class_id": 5677522, + "name": "value_r_8kwi9s", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677523, + "project_id": 1114489, + "type": "object", + "name": "r_rdwn52", + "color": "#FFEAA7", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520537, + "group_type": "text", + "class_id": 5677523, + "name": "value_r_rdwn52", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677524, + "project_id": 1114489, + "type": "object", + "name": "r_vcemqe", + "color": "#96CEB4", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520538, + "group_type": "text", + "class_id": 5677524, + "name": "value_r_vcemqe", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677525, + "project_id": 1114489, + "type": "object", + "name": "r_g8605a", + "color": "#FFEAA7", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520539, + "group_type": "radio", + "class_id": 5677525, + "name": "value_r_g8605a", + "attributes": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263866, + "group_id": 5520539, + "project_id": 1114489, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263867, + "group_id": 5520539, + "project_id": 1114489, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263868, + "group_id": 5520539, + "project_id": 1114489, + "name": "Option 3", + "default": 0 + } + ], + "default_value": null + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677526, + "project_id": 1114489, + "type": "object", + "name": "r_do97d1", + "color": "#45B7D1", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520540, + "group_type": "checklist", + "class_id": 5677526, + "name": "value_r_do97d1", + "attributes": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263869, + "group_id": 5520540, + "project_id": 1114489, + "name": "Option 1", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263870, + "group_id": 5520540, + "project_id": 1114489, + "name": "Option 2", + "default": 0 + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 11263871, + "group_id": 5520540, + "project_id": 1114489, + "name": "Option 3", + "default": 1 + } + ], + "default_value": [ + "Option 3" + ] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677527, + "project_id": 1114489, + "type": "object", + "name": "r_qoijyc", + "color": "#2D1C8F", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520541, + "group_type": "text", + "class_id": 5677527, + "name": "value_r_qoijyc", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677528, + "project_id": 1114489, + "type": "object", + "name": "r_kz08gn", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520542, + "group_type": "numeric", + "class_id": 5677528, + "name": "value_r_kz08gn", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677529, + "project_id": 1114489, + "type": "object", + "name": "r_dfgb9b", + "color": "#4ECDC4", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520543, + "group_type": "numeric", + "class_id": 5677529, + "name": "value_r_dfgb9b", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677530, + "project_id": 1114489, + "type": "object", + "name": "r_ijokdm", + "color": "#FFEAA7", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520544, + "group_type": "text", + "class_id": 5677530, + "name": "value_r_ijokdm", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677531, + "project_id": 1114489, + "type": "object", + "name": "r_innmmg", + "color": "#45B7D1", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520545, + "group_type": "text", + "class_id": 5677531, + "name": "value_r_innmmg", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677532, + "project_id": 1114489, + "type": "object", + "name": "r_jum7tm", + "color": "#45B7D1", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520546, + "group_type": "text", + "class_id": 5677532, + "name": "value_r_jum7tm", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677533, + "project_id": 1114489, + "type": "object", + "name": "r_gl0gfo", + "color": "#AC40F2", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520547, + "group_type": "text", + "class_id": 5677533, + "name": "value_r_gl0gfo", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677534, + "project_id": 1114489, + "type": "object", + "name": "r_qx07c6", + "color": "#FF6B6B", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520548, + "group_type": "text", + "class_id": 5677534, + "name": "value_r_qx07c6", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677535, + "project_id": 1114489, + "type": "object", + "name": "r_mhw45u", + "color": "#F4E1C2", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520549, + "group_type": "text", + "class_id": 5677535, + "name": "value_r_mhw45u", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677536, + "project_id": 1114489, + "type": "object", + "name": "r_a4o5pi", + "color": "#45B7D1", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520550, + "group_type": "text", + "class_id": 5677536, + "name": "value_r_a4o5pi", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677537, + "project_id": 1114489, + "type": "object", + "name": "r_5ildf5", + "color": "#4ECDC4", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520551, + "group_type": "text", + "class_id": 5677537, + "name": "value_r_5ildf5", + "attributes": [] + } + ] + }, + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5677538, + "project_id": 1114489, + "type": "object", + "name": "r_s5j1j7", + "color": "#F4E1C2", + "attribute_groups": [ + { + "createdAt": "2025-08-18T08:48:37.000Z", + "updatedAt": "2025-08-18T08:48:37.000Z", + "id": 5520552, + "group_type": "text", + "class_id": 5677538, + "name": "value_r_s5j1j7", + "attributes": [] + } + ] + } +] \ No newline at end of file diff --git a/tests/data_set/multimodal_form/form.json b/tests/data_set/multimodal_form/form.json new file mode 100644 index 000000000..e4164c7e4 --- /dev/null +++ b/tests/data_set/multimodal_form/form.json @@ -0,0 +1,500 @@ +{ + "components": [ + { + "id": "r_hf74r2", + "type": "container", + "permissions": [], + "title": "", + "collapsible": false, + "children": [ + { + "id": "r_v9duzy", + "type": "group", + "permissions": [], + "title": "", + "collapsible": false, + "children": [ + { + "id": "r_jqjdt6", + "type": "grid", + "permissions": [], + "align": "top", + "isResizable": false, + "children": [ + { + "id": "r_qir6k4", + "span": 12, + "sticky": false, + "children": [ + { + "id": "r_brpz8i", + "type": "tabs", + "permissions": [], + "children": [ + { + "id": "r_e0muii", + "name": "Tab 1", + "children": [ + { + "id": "r_g513mi", + "type": "divider", + "permissions": [], + "hasTooltip": false, + "label": "Section name" + }, + { + "id": "r_nyepjt", + "type": "button", + "permissions": [], + "hasTooltip": false, + "align": "left", + "variant": "primary", + "text": "Button", + "showIcon": false, + "showLabel": true + }, + { + "id": "r_c77v29", + "type": "markdown", + "permissions": [], + "hasTooltip": false, + "labelFormat": { + "size": 20, + "color": "#5C1818", + "bold": true, + "italic": true + }, + "label": "Markdown", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false + }, + { + "id": "r_q3j6wn", + "type": "textarea", + "permissions": [ + { + "roles": "all", + "statuses": [ + 3 + ], + "visibility": 2 + } + ], + "hasTooltip": false, + "label": "Text area", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "placeholder": "Placeholder", + "min": 0, + "max": 512 + }, + { + "id": "r_66b5q3", + "type": "checkbox", + "permissions": [], + "hasTooltip": false, + "label": "Checkbox", + "isRequired": false, + "value": [], + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "Option 1", + "checked": false + }, + { + "value": "Option 2", + "checked": false + }, + { + "value": "Option 3", + "checked": false + } + ], + "layout": "column" + }, + { + "id": "r_923kb6", + "type": "radio", + "permissions": [], + "hasTooltip": false, + "label": "Radio", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "Option 1", + "checked": false + }, + { + "value": "Option 2", + "checked": false + }, + { + "value": "Option 3", + "checked": false + } + ], + "layout": "column" + }, + { + "id": "r_88fb2z", + "type": "image", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "", + "alt": "", + "layout": "cover", + "filter": { + "brightness": 0, + "contrast": 0 + } + }, + { + "id": "r_tz83ki", + "type": "pdfComponent", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "" + }, + { + "id": "r_9r65ca", + "type": "url", + "permissions": [], + "hasTooltip": false, + "exclude": true, + "value": "" + }, + { + "id": "r_sqrzza", + "type": "webComponent", + "permissions": [], + "hasTooltip": false, + "exclude": true, + "value": "", + "code": "" + } + ] + }, + { + "id": "r_dikipz", + "name": "Tab 2", + "children": [] + } + ] + } + ] + }, + { + "id": "r_96a7vd", + "span": 12, + "sticky": false, + "children": [ + { + "id": "r_e7a4za", + "type": "number", + "permissions": [], + "hasTooltip": false, + "label": "Number", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "min": null, + "max": null, + "step": 1 + }, + { + "id": "r_8kwi9s", + "type": "code", + "permissions": [], + "hasTooltip": false, + "label": "Code", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "language": null + }, + { + "id": "r_rdwn52", + "type": "paragraph", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "align": "left", + "label": "Paragraph label", + "value": "Paragraph text" + }, + { + "id": "r_vcemqe", + "type": "input", + "permissions": [], + "hasTooltip": false, + "label": "Text input", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "placeholder": "Placeholder", + "min": 0, + "max": 300 + }, + { + "id": "r_g8605a", + "type": "select", + "permissions": [], + "hasTooltip": false, + "resize": { + "width": 200 + }, + "label": "Select", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "Option 1", + "checked": false + }, + { + "value": "Option 2", + "checked": false + }, + { + "value": "Option 3", + "checked": false + } + ], + "isMultiselect": false, + "placeholder": "Select", + "isDynamic": false + }, + { + "id": "r_do97d1", + "type": "select", + "permissions": [], + "hasTooltip": false, + "label": "Select", + "isRequired": true, + "value": [ + "Option 3" + ], + "exclude": false, + "disablePasting": false, + "options": [ + { + "value": "Option 1", + "checked": false + }, + { + "value": "Option 2", + "checked": false + }, + { + "value": "Option 3", + "checked": true + } + ], + "isMultiselect": true, + "placeholder": "Select", + "isDynamic": false + }, + { + "id": "r_qoijyc", + "type": "select", + "permissions": [], + "hasTooltip": false, + "label": "Select", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "options": [], + "isMultiselect": false, + "placeholder": "Select", + "isDynamic": true + }, + { + "id": "r_kz08gn", + "type": "voting", + "permissions": [], + "hasTooltip": false, + "label": "Voting", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false + }, + { + "id": "r_dfgb9b", + "type": "rating", + "permissions": [], + "hasTooltip": false, + "label": "Rating", + "isRequired": false, + "value": null, + "exclude": false, + "disablePasting": false, + "numberOfStars": 5 + }, + { + "id": "r_ijokdm", + "type": "dateTime", + "permissions": [], + "hasTooltip": false, + "label": "Date", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false, + "showTime": false + }, + { + "id": "r_innmmg", + "type": "time", + "permissions": [], + "hasTooltip": true, + "tooltip": "Enter your text here to see it in the info tooltip", + "label": "Time", + "isRequired": false, + "value": "", + "exclude": false, + "disablePasting": false + } + ] + } + ] + }, + { + "id": "r_jum7tm", + "type": "pdfComponent", + "permissions": [ + { + "roles": [ + 3 + ], + "statuses": [ + 2 + ], + "visibility": 1 + } + ], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "" + }, + { + "id": "r_hcujf7", + "type": "url", + "permissions": [], + "hasTooltip": false, + "exclude": true, + "value": "" + }, + { + "id": "r_gl0gfo", + "type": "video", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "" + } + ], + "index": 0, + "isTemplate": false, + "deletable": false, + "countable": false, + "selectable": false + } + ] + }, + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "" + }, + { + "id": "r_mhw45u", + "type": "avatar", + "permissions": [], + "hasTooltip": false, + "exclude": false, + "label": "", + "value": "", + "alt": "" + }, + { + "id": "r_a4o5pi", + "type": "csv", + "permissions": [], + "hasTooltip": false, + "label": "", + "isRequired": false, + "value": ",,,\n,,,\n,,,\n,,,", + "exclude": false, + "disablePasting": false, + "delimiter": "," + }, + { + "id": "r_5ildf5", + "type": "csv", + "permissions": [], + "hasTooltip": false, + "label": "", + "isRequired": false, + "value": ";;;\r\n;;;\r\n;;;\r\n;;;", + "exclude": false, + "disablePasting": false, + "delimiter": ";" + }, + { + "id": "r_s5j1j7", + "type": "csv", + "permissions": [], + "hasTooltip": false, + "label": "dasdasdas", + "isRequired": false, + "value": "|||\r\n|||\r\n|||\r\n|||", + "exclude": false, + "disablePasting": false, + "delimiter": "|" + } + ], + "code": [ + [ + "__init__", + "from typing import List, Union\n# import requests.asyncs as requests\nimport requests\nimport sa\n\ngroup_r_v9duzy = ['r_v9duzy']\ntabs_r_brpz8i = ['r_v9duzy', 0, 'r_brpz8i']\ndivider_r_g513mi = ['r_v9duzy', 0, 'r_g513mi']\nbutton_r_nyepjt = ['r_v9duzy', 0, 'r_nyepjt']\nmarkdown_r_c77v29 = ['r_v9duzy', 0, 'r_c77v29']\ntextarea_r_q3j6wn = ['r_v9duzy', 0, 'r_q3j6wn']\ncheckbox_r_66b5q3 = ['r_v9duzy', 0, 'r_66b5q3']\nradio_r_923kb6 = ['r_v9duzy', 0, 'r_923kb6']\nimage_r_88fb2z = ['r_v9duzy', 0, 'r_88fb2z']\npdfComponent_r_tz83ki = ['r_v9duzy', 0, 'r_tz83ki']\nurl_r_9r65ca = ['r_v9duzy', 0, 'r_9r65ca']\nwebComponent_r_sqrzza = ['r_v9duzy', 0, 'r_sqrzza']\nnumber_r_e7a4za = ['r_v9duzy', 0, 'r_e7a4za']\ncode_r_8kwi9s = ['r_v9duzy', 0, 'r_8kwi9s']\nparagraph_r_rdwn52 = ['r_v9duzy', 0, 'r_rdwn52']\ninput_r_vcemqe = ['r_v9duzy', 0, 'r_vcemqe']\nselect_r_g8605a = ['r_v9duzy', 0, 'r_g8605a']\nselect_r_do97d1 = ['r_v9duzy', 0, 'r_do97d1']\nselect_r_qoijyc = ['r_v9duzy', 0, 'r_qoijyc']\nvoting_r_kz08gn = ['r_v9duzy', 0, 'r_kz08gn']\nrating_r_dfgb9b = ['r_v9duzy', 0, 'r_dfgb9b']\ndateTime_r_ijokdm = ['r_v9duzy', 0, 'r_ijokdm']\ntime_r_innmmg = ['r_v9duzy', 0, 'r_innmmg']\npdfComponent_r_jum7tm = ['r_v9duzy', 0, 'r_jum7tm']\nurl_r_hcujf7 = ['r_v9duzy', 0, 'r_hcujf7']\nvideo_r_gl0gfo = ['r_v9duzy', 0, 'r_gl0gfo']\naudio_r_qx07c6 = ['r_qx07c6']\navatar_r_mhw45u = ['r_mhw45u']\ncsv_r_a4o5pi = ['r_a4o5pi']\ncsv_r_5ildf5 = ['r_5ildf5']\ncsv_r_s5j1j7 = ['r_s5j1j7']\n\ndef handle_group_delete(path: List[Union[str, int]]):\n print('Print anything')\n return\n\ndef on_r_v9duzy_deleted(path: List[Union[str, int]]):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n\n # Your code goes here\n return\n\ndef on_r_v9duzy_selected(path: List[Union[str, int]]):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n\n # Your code goes here\n return\n\ndef on_r_v9duzy_deselected(path: List[Union[str, int]]):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n\n # Your code goes here\n return\n\ndef on_r_nyepjt_click(path: List[Union[str, int]]):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n\n # Your code goes here\n return\n\ndef on_r_c77v29_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_q3j6wn_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_66b5q3_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_923kb6_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_sqrzza_message(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_sqrzza_wcevent(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_e7a4za_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_8kwi9s_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_vcemqe_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_g8605a_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_do97d1_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_qoijyc_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_kz08gn_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_dfgb9b_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_ijokdm_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_innmmg_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_a4o5pi_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_5ildf5_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n\ndef on_r_s5j1j7_change(path: List[Union[str, int]], value):\n # The path is a list of strings and integers, the length of which is always an odd number and not less than 1.\n # The last value is the identifier of the form element and the pairs preceding it are\n # the group identifiers and the subgroup index, respectively\n # value is current value of the form element\n\n # Your code goes here\n return\n" + ] + ], + "environments": [], + "meta": { + "statusesMap": { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6 + } + }, + "readme": "" +} \ No newline at end of file diff --git a/tests/integration/annotations/test_large_annotations.py b/tests/integration/annotations/test_large_annotations.py index e57abd7eb..be33f27dc 100644 --- a/tests/integration/annotations/test_large_annotations.py +++ b/tests/integration/annotations/test_large_annotations.py @@ -16,7 +16,7 @@ class TestAnnotationUploadVector(BaseTestCase): - PROJECT_NAME = "Test-upload_annotations" + PROJECT_NAME = "Test-upload_large_annotations" PROJECT_DESCRIPTION = "Desc" PROJECT_TYPE = "Vector" TEST_FOLDER_PATH = "data_set/sample_vector_annotations_with_tag_classes" @@ -59,7 +59,7 @@ def test_large_annotation_upload(self): self.PROJECT_NAME, [annotation] ).values() assert ( - "INFO:sa:Uploading 1/1 annotations to the project Test-upload_annotations." + "INFO:sa:Uploading 1/1 annotations to the project Test-upload_large_annotations." == cm.output[0] ) assert len(uploaded) == 1 @@ -84,7 +84,7 @@ def test_large_annotations_upload_get_download(self): self.PROJECT_NAME, annotations ).values() assert ( - "INFO:sa:Uploading 5/5 annotations to the project Test-upload_annotations." + "INFO:sa:Uploading 5/5 annotations to the project Test-upload_large_annotations." == cm.output[0] ) assert len(uploaded) == 5 @@ -92,7 +92,7 @@ def test_large_annotations_upload_get_download(self): with self.assertLogs("sa", level="INFO") as cm: annotations = sa.get_annotations(self.PROJECT_NAME) assert ( - "INFO:sa:Getting 5 annotations from Test-upload_annotations." + "INFO:sa:Getting 5 annotations from Test-upload_large_annotations." == cm.output[0] ) assert len(annotations) == 5 diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index dd442a8e0..374813bd7 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -146,6 +146,20 @@ class MultiModalUploadDownloadAnnotations(BaseTestCase): DATA_SET_PATH / "multimodal/annotations/jsonl/form1_with_categories.jsonl" ) CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates" / "form1_classes.json" + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } def setUp(self, *args, **kwargs): self.tearDown() @@ -157,6 +171,7 @@ def setUp(self, *args, **kwargs): {"attribute": "CategorizeItems", "value": 1}, {"attribute": "TemplateState", "value": 1}, ], + form=self.MULTIMODAL_FORM, ) project = sa.controller.get_project(self.PROJECT_NAME) # todo check diff --git a/tests/integration/items/test_attach_category.py b/tests/integration/items/test_attach_category.py index 2b23fee92..18195e2b7 100644 --- a/tests/integration/items/test_attach_category.py +++ b/tests/integration/items/test_attach_category.py @@ -22,6 +22,20 @@ class TestItemAttachCategory(TestCase): Path(__file__).parent.parent.parent, "data_set/editor_templates/form1_classes.json", ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } @classmethod def setUpClass(cls, *args, **kwargs) -> None: @@ -34,6 +48,7 @@ def setUpClass(cls, *args, **kwargs) -> None: {"attribute": "TemplateState", "value": 1}, {"attribute": "CategorizeItems", "value": 1}, ], + form=cls.MULTIMODAL_FORM, ) team = sa.controller.team project = sa.controller.get_project(cls.PROJECT_NAME) diff --git a/tests/integration/items/test_generate_items.py b/tests/integration/items/test_generate_items.py index 3b1c6d26c..50e2c7eaa 100644 --- a/tests/integration/items/test_generate_items.py +++ b/tests/integration/items/test_generate_items.py @@ -1,15 +1,50 @@ +from unittest import TestCase + from src.superannotate import AppException from src.superannotate import SAClient -from tests.integration.base import BaseTestCase sa = SAClient() -class TestGenerateItemsMM(BaseTestCase): +class TestGenerateItemsMM(TestCase): PROJECT_NAME = "TestGenerateItemsMM" PROJECT_DESCRIPTION = "TestGenerateItemsMM" PROJECT_TYPE = "Multimodal" FOLDER_NAME = "test_folder" + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self, *args, **kwargs): + sa.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[{"attribute": "TemplateState", "value": 1}], + form=self.MULTIMODAL_FORM, + ) + + def tearDown(self) -> None: + try: + projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) + for project in projects: + try: + sa.delete_project(project) + except Exception as e: + print(str(e)) + except Exception as e: + print(str(e)) def test_generate_items_root(self): sa.generate_items(self.PROJECT_NAME, 100, name="a") diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 641acf6b4..1877e0684 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -20,6 +20,20 @@ class TestMultimodalProjectBasic(BaseTestCase): Path(__file__).parent.parent.parent, "data_set/editor_templates/form1_classes.json", ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } def setUp(self, *args, **kwargs): self.tearDown() @@ -28,6 +42,7 @@ def setUp(self, *args, **kwargs): self.PROJECT_DESCRIPTION, self.PROJECT_TYPE, settings=[{"attribute": "TemplateState", "value": 1}], + form=self.MULTIMODAL_FORM, ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index bd490bd3e..5caf0be24 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -78,6 +78,20 @@ class TestListItemsMultimodal(BaseTestCase): ] CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1_classes.json" EDITOR_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1.json" + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } def setUp(self, *args, **kwargs): self.tearDown() @@ -89,6 +103,7 @@ def setUp(self, *args, **kwargs): {"attribute": "CategorizeItems", "value": 1}, {"attribute": "TemplateState", "value": 1}, ], + form=self.MULTIMODAL_FORM, ) project = sa.controller.get_project(self.PROJECT_NAME) with open(self.EDITOR_TEMPLATE_PATH) as f: diff --git a/tests/integration/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 6991dceed..4674b0384 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -159,6 +159,7 @@ def test_create_project(self, track_method): "workflows": None, "workflow": None, "instructions_link": None, + "form": None, } try: sa.create_project(**kwargs) diff --git a/tests/integration/projects/test_basic_project.py b/tests/integration/projects/test_basic_project.py index 6f17ab21d..726ced58b 100644 --- a/tests/integration/projects/test_basic_project.py +++ b/tests/integration/projects/test_basic_project.py @@ -18,6 +18,35 @@ class TestMultimodalProjectBasic(BaseTestCase): ANNOTATION_PATH = ( "data_set/sample_project_vector/example_image_1.jpg___objects.json" ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self, *args, **kwargs): + self.tearDown() + self._project = sa.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + form=self.MULTIMODAL_FORM, + ) + + def tearDown(self) -> None: + try: + sa.delete_project(self.PROJECT_NAME) + except Exception as e: + print(str(e)) @property def annotation_path(self): diff --git a/tests/integration/projects/test_multimodal_creation.py b/tests/integration/projects/test_multimodal_creation.py new file mode 100644 index 000000000..10ddd65ef --- /dev/null +++ b/tests/integration/projects/test_multimodal_creation.py @@ -0,0 +1,221 @@ +import json +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch + +from src.superannotate import AppException +from src.superannotate import SAClient +from src.superannotate.lib.core.entities.classes import ClassTypeEnum +from tests import DATA_SET_PATH + +sa = SAClient() + + +class ProjectCreateBaseTestCase(TestCase): + PROJECT_NAME = "PROJECT" + + def setUp(self, *args, **kwargs): + self.tearDown() + + def tearDown(self) -> None: + try: + sa.delete_project(self.PROJECT_NAME) + except AppException: + ... + + +class TestCreateMultimodalProject(ProjectCreateBaseTestCase): + PROJECT_TYPE = "Multimodal" + PROJECT_NAME = "test_multimodal_create" + FORM_PATH = DATA_SET_PATH / "multimodal_form" / "form.json" + EXPECTED_CLASSES = DATA_SET_PATH / "multimodal_form" / "expected_classes.json" + + def test_create_project_with_invalid_form(self): + """Test project creation with invalid form data""" + invalid_form = {"invalid": "data"} + + with self.assertRaises(AppException): + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + form=invalid_form, + ) + + def test_create_project_form_only_multimodal(self): + """Test that form parameter only works with multimodal projects""" + with open(self.FORM_PATH) as f: + form_data = json.load(f) + + # Should work for multimodal + project = sa.create_project( + self.PROJECT_NAME, + "desc", + "Multimodal", + form=form_data, + ) + assert project["type"] == "Multimodal" + + def test_create_project_with_form_classes(self): + """Test that multimodal project creation with form generates expected classes""" + with open(self.FORM_PATH) as f: + form_data = json.load(f) + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + form=form_data, + ) + + # Get project classes after form attachment + project_classes = sa.search_annotation_classes(self.PROJECT_NAME) + + # Load expected classes + with open(self.EXPECTED_CLASSES) as f: + expected_classes = json.load(f) + + # Compare lengths + assert len(project_classes) == len( + expected_classes + ), f"Expected {len(expected_classes)} classes, got {len(project_classes)}" + + # Sort both lists by name for consistent comparison + expected_sorted = sorted(expected_classes, key=lambda x: x["name"]) + generated_sorted = sorted(project_classes, key=lambda x: x["name"]) + + # Compare each class + for i, (expected, generated) in enumerate( + zip(expected_sorted, generated_sorted) + ): + # Compare structure (excluding color since it's random and timestamps) + assert ( + generated["name"] == expected["name"] + ), f"Class {i}: name mismatch - expected {expected['name']}, got {generated['name']}" + + # todo check + # assert generated["count"] == expected["count"], \ + # f"Class {i}: count mismatch" + + assert ( + ClassTypeEnum(expected["type"]).name == generated["type"] + ), f"Class {i}: type mismatch" + + assert len(generated["attribute_groups"]) == len( + expected["attribute_groups"] + ), f"Class {i}: attribute_groups length mismatch" + + # Compare attribute groups + for j, (exp_group, gen_group) in enumerate( + zip(expected["attribute_groups"], generated["attribute_groups"]) + ): + assert ( + gen_group["name"] == exp_group["name"] + ), f"Class {i}, group {j}: name mismatch" + + assert ( + gen_group["group_type"] == exp_group["group_type"] + ), f"Class {i}, group {j}: group_type mismatch" + + assert len(gen_group["attributes"]) == len( + exp_group["attributes"] + ), f"Class {i}, group {j}: attributes length mismatch" + + # Compare attributes + for k, (exp_attr, gen_attr) in enumerate( + zip(exp_group["attributes"], gen_group["attributes"]) + ): + assert ( + gen_attr["name"] == exp_attr["name"] + ), f"Class {i}, group {j}, attr {k}: name mismatch" + # todo check + # assert gen_attr["count"] == exp_attr["count"], \ + # f"Class {i}, group {j}, attr {k}: count mismatch" + assert ( + gen_attr["default"] == exp_attr["default"] + ), f"Class {i}, group {j}, attr {k}: default mismatch" + + def test_create_project_multimodal_no_form(self): + """Test that multimodal project creation fails when no form is provided""" + with self.assertRaises(AppException) as context: + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + ) + assert "A form object is required when creating a Multimodal project." in str( + context.exception + ) + + def test_create_project_multimodal_with_classes(self): + """Test that multimodal project creation fails when classes are provided""" + classes = [ + { + "type": 1, + "name": "Test Class", + "color": "#FF0000", + } + ] + + with self.assertRaises(AppException) as context: + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + classes=classes, + form={"components": []}, + ) + assert "Classes cannot be provided for Multimodal projects." in str( + context.exception + ) + + def test_create_project_invalid_form_structure(self): + """Test project creation with structurally invalid form data""" + invalid_form = { + "invalid_key": [ + {"type": "invalid_component", "missing_required_fields": True} + ] + } + + with self.assertRaises(AppException): + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + form=invalid_form, + ) + + def test_create_project_form_attach_failure_cleanup(self): + """Test that project is deleted when form attachment fails""" + with open(self.FORM_PATH) as f: + form_data = json.load(f) + + # Mock the controller to simulate form attachment failure + with patch.object( + sa.controller.projects, "attach_form" + ) as mock_attach_form, patch.object( + sa.controller.projects, "delete" + ) as mock_delete: + # Create a mock response that raises AppException when raise_for_status is called + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = AppException( + "Form attachment failed" + ) + mock_attach_form.return_value = mock_response + + # Attempt to create project - should fail and trigger cleanup + with self.assertRaises(AppException) as context: + sa.create_project( + self.PROJECT_NAME, + "desc", + self.PROJECT_TYPE, + form=form_data, + ) + + # Verify form attachment was attempted + mock_attach_form.assert_called_once() + + # Verify project deletion was called for cleanup + mock_delete.assert_called_once() + + # Verify the original exception is re-raised + assert "Form attachment failed" in str(context.exception) diff --git a/tests/integration/work_management/test_contributors_categories.py b/tests/integration/work_management/test_contributors_categories.py index 426cf1fff..9679d8d5a 100644 --- a/tests/integration/work_management/test_contributors_categories.py +++ b/tests/integration/work_management/test_contributors_categories.py @@ -22,6 +22,20 @@ class TestContributorsCategories(TestCase): Path(__file__).parent.parent.parent, "data_set/editor_templates/form1_classes.json", ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } @classmethod def setUpClass(cls, *args, **kwargs) -> None: @@ -34,6 +48,7 @@ def setUpClass(cls, *args, **kwargs) -> None: {"attribute": "TemplateState", "value": 1}, {"attribute": "CategorizeItems", "value": 2}, ], + form=cls.MULTIMODAL_FORM, ) team = sa.controller.team project = sa.controller.get_project(cls.PROJECT_NAME) diff --git a/tests/integration/work_management/test_project_categories.py b/tests/integration/work_management/test_project_categories.py index 2a63d4c7e..87a3328f8 100644 --- a/tests/integration/work_management/test_project_categories.py +++ b/tests/integration/work_management/test_project_categories.py @@ -22,6 +22,20 @@ class TestProjectCategories(TestCase): Path(__file__).parent.parent.parent, "data_set/editor_templates/form1_classes.json", ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } @classmethod def setUpClass(cls, *args, **kwargs) -> None: @@ -34,6 +48,7 @@ def setUpClass(cls, *args, **kwargs) -> None: {"attribute": "TemplateState", "value": 1}, {"attribute": "CategorizeItems", "value": 1}, ], + form=cls.MULTIMODAL_FORM, ) team = sa.controller.team project = sa.controller.get_project(cls.PROJECT_NAME) diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index 9dc256868..417363734 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -5,7 +5,6 @@ from lib.core.exceptions import AppException from src.superannotate import SAClient from src.superannotate.lib.core.enums import CustomFieldEntityEnum -from tests.integration.base import BaseTestCase from tests.integration.work_management.data_set import CUSTOM_FIELD_PAYLOADS @@ -244,19 +243,3 @@ def test_set_user_custom_field_validation(self): error_template_select.format(type="str", options="option1, option2"), ): sa.set_user_custom_field(scapegoat["email"], "SDK_test_single_select", 123) - - -class TestUserProjectCustomFields(BaseTestCase): - PROJECT_NAME = "TestUserProjectCustomFields" - PROJECT_TYPE = "Multimodal" - PROJECT_DESCRIPTION = "Multimodal" - - def test_project_custom_fields(self): - scapegoat = sa.list_users(role="contributor")[0] - sa.add_contributors_to_project( - self.PROJECT_NAME, [scapegoat["email"]], role="QA" - ) - users = sa.list_users(project=self.PROJECT_NAME) - assert users[0]["role"] == "QA" - users = sa.list_users(project=self.PROJECT_NAME, include=["custom_fields"]) - assert users[0]["role"] == "QA" diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index 267d78f70..21c978d47 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -28,6 +28,20 @@ class TestUserScoring(TestCase): Path(__file__).parent.parent.parent, "data_set/editor_templates/form1_classes.json", ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } @classmethod def setUpClass(cls, *args, **kwargs) -> None: @@ -39,6 +53,7 @@ def setUpClass(cls, *args, **kwargs) -> None: cls.PROJECT_DESCRIPTION, cls.PROJECT_TYPE, settings=[{"attribute": "TemplateState", "value": 1}], + form=cls.MULTIMODAL_FORM, ) team = sa.controller.team project = sa.controller.get_project(cls.PROJECT_NAME)