diff --git a/doc/source/index.rst b/doc/source/index.rst index f0aadd2794..e5709f0982 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -7,6 +7,7 @@ PyDynamicReporting gettingstarted/index userguide/index + userguide/json_importer_models class_documentation examples/index serverless/index diff --git a/doc/source/serverless/index.rst b/doc/source/serverless/index.rst index 4027c3086a..7c83f9df8d 100644 --- a/doc/source/serverless/index.rst +++ b/doc/source/serverless/index.rst @@ -59,6 +59,7 @@ Explore the docs instantiation sessions_and_datasets items + json_importer templates querying media_and_static diff --git a/doc/source/serverless/json_importer.rst b/doc/source/serverless/json_importer.rst new file mode 100644 index 0000000000..1c6a78fa4b --- /dev/null +++ b/doc/source/serverless/json_importer.rst @@ -0,0 +1,553 @@ +JSON Importer +============= + +The JSON importer provides a structured way to create ADR report items from a +JSON payload in serverless workflows. + +The importer logic lives in the serverless package, while payload validation is +centralized in shared Pydantic models under utils so the same schema can be +reused by both server and serverless import versions. + +Serverless Importer +------------------- + +Use ``ADR.import_json_items`` to load a JSON file, validate it through +the shared schema models, create ADR items, and generate a root report +template from the imported payload. + +During import, the top-level ``app_id`` is used to build the template header +and top-level ``tags`` are applied as template-level tags so imported items are +grouped under the generated report context. + +.. code-block:: python + + from ansys.dynamicreporting.core.serverless import ADR + + adr = ADR(ansys_installation=r"...", db_directory=r"...") + adr.setup() + data = adr.import_json_items(r"...\report_items.json") + print(data.app_id, len(data.items)) + +Supported item types in the current schema are: + +- ``text`` +- ``table`` +- ``image`` +- ``file`` +- ``scene`` +- ``tree`` + +.. automodule:: ansys.dynamicreporting.core.serverless.json_importer + :members: ServerlessReportItemImporter + :undoc-members: + :show-inheritance: + + +Shared Validation Models +------------------------ + +The shared Pydantic validation models are documented in +:doc:`../userguide/json_importer_models`. + + + +ADR JSON Report Items Schema Guide +---------------------------------- + +This document explains what the schema validates and how to use it when creating or checking report payload files. + +JSON Schema (Production) +------------------------ + +This is the production schema used for validating JSON report item payloads: + +.. code-block:: json + + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.local/adr-json-report-items/report-items.schema.json", + "title": "ADR JSON Report Items", + "type": "object", + "additionalProperties": true, + "required": [ + "app_id", + "tags", + "items" + ], + "properties": { + "app_id": { + "type": "string" + }, + "tags": { + "$ref": "#/$defs/tagsArray" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/item" + } + } + }, + "$defs": { + "tagObject": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "string" + } + }, + "tagsArray": { + "type": "array", + "items": { + "$ref": "#/$defs/tagObject" + } + }, + "propertyObject": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + } + }, + "itemBase": { + "type": "object", + "required": [ + "item_type", + "name", + "tags" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "tags": { + "$ref": "#/$defs/tagsArray" + }, + "properties": { + "type": "array", + "items": { + "$ref": "#/$defs/propertyObject" + } + } + } + }, + "textItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "value" + ], + "properties": { + "item_type": { + "const": "text" + }, + "value": { + "type": "string" + } + } + } + ] + }, + "imageItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "src" + ], + "properties": { + "item_type": { + "const": "image" + }, + "src": { + "type": "string" + } + } + } + ] + }, + "fileItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "src" + ], + "properties": { + "item_type": { + "const": "file" + }, + "src": { + "type": "string" + } + } + } + ] + }, + "sceneItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "src" + ], + "properties": { + "item_type": { + "const": "scene" + }, + "src": { + "type": "string" + } + } + } + ] + }, + "tableItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "columns", + "rows" + ], + "properties": { + "item_type": { + "const": "table" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "plot": { + "type": "string" + }, + "xaxis": { + "type": "string" + }, + "yaxis": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "treeNode": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/treeNode" + } + } + }, + "additionalProperties": true + }, + "treeItem": { + "allOf": [ + { + "$ref": "#/$defs/itemBase" + }, + { + "type": "object", + "required": [ + "item_type", + "data" + ], + "properties": { + "item_type": { + "const": "tree" + }, + "data": { + "type": "object", + "required": [ + "nodes" + ], + "properties": { + "nodes": { + "type": "array", + "items": { + "$ref": "#/$defs/treeNode" + } + } + }, + "additionalProperties": true + } + } + } + ] + }, + "item": { + "oneOf": [ + { + "$ref": "#/$defs/textItem" + }, + { + "$ref": "#/$defs/imageItem" + }, + { + "$ref": "#/$defs/fileItem" + }, + { + "$ref": "#/$defs/sceneItem" + }, + { + "$ref": "#/$defs/tableItem" + }, + { + "$ref": "#/$defs/treeItem" + } + ] + } + } + } + +Sample JSON payload: + +.. code-block:: json + + { + "app_id": "adr-mechanical", + "tags": [ + { "report": "template_json_import" } + ], + "items": [ + { + "item_type": "text", + "name": "Summary", + "value": "Simulation completed successfully.", + "tags": [ + { "section": "child_json_import" } + ] + }, + { + "item_type": "text", + "name": "Formatted Report HTML", + "value": "

Report

All results are within range.

", + "tags": [ + { "section": "child_json_import" } + ] + }, + { + "item_type": "image", + "name": "Result Plot", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/image.png", + "tags": [ + { "section": "child_json_import" } + ] + }, + { + "item_type": "file", + "name": "Raw Data File", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/results.csv", + "tags": [ + { "section": "child_json_import" } + ] + }, + { + "item_type": "scene", + "name": "3D Scene", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/scene.avz", + "tags": [ + { "section": "child_json_import" } + ] + }, + { + "item_type": "table", + "name": "Temperature Table One", + "tags": [ + { "section": "child_json_import" } + ], + "columns": [], + "rows": [ + [0.5, 10, 101325], + [0.5, 12, 101300], + [1, 15, 101280] + ] + }, + { + "item_type": "table", + "name": "Temperature Table One", + "tags": [ + { "section": "child_json_import" } + ], + "columns": ["X", "Sin", "Cos"], + "rows": [[1, 2], [3, 4]], + "plot": "line", + "xaxis": "X", + "yaxis": ["Sin", "Cos"], + "properties": [ + { "format" : "floatdot0" }, + { "zaxis": "Z" }, + { "xaxis_format": "floatdot0" }, + { "ytitle": "Values" }, + { "ytitle": "X" } + ] + }, + { + "item_type": "tree", + "name": "Hierarchy", + "tags": [ + { "section": "child_json_import" } + ], + "data": { + "nodes": [ + { + "name": "Parent-Root", + "children": [ + { "name": "Child One", "children": [ {"name":"Child One One"} ] }, + { + "name": "Child Two", + "children": [ + { "name": "Grandchild One", "children": [ {"name":"Child Two Two"} ] } + ] + } + ] + } + ] + } + } + ] + } + +What this schema is for +^^^^^^^^^^^^^^^^^^^^^^^ + +The schema validates a report items with this top-level structure: +- app_id: application identifier +- tags: report-level tags (array of key/value objects) +- items: list of report items + +It is designed for the current sample format where table items use top-level columns and rows. + +Supported item types +^^^^^^^^^^^^^^^^^^^^ + +Each object in items must include: +- item_type +- name +- tags + +Then each type has its own required fields: +- text: value +- image: src +- file: src +- scene: src +- table: columns and rows +- tree: data.nodes + +Optional common fields: +- properties + +You can omit properties entirely. It is optional for all item types. + +Tag format +^^^^^^^^^^ + +Tags are arrays of objects with one or more string values. + +Examples: +- [ { "report": "json_import_tags" } ] +- [ { "section": "json_import_tags_1" } ] +- [ { "report": "json_import_tags" }, { "section": "json_import_tags_1" } ] + +How to validate in VS Code +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. Open the JSON file you want to validate. +2. Add this line at the top of the JSON file: + "$schema": "./report_items.schema.json", +3. Save the file. +4. VS Code will show schema validation errors in the Problems panel if any fields are missing or invalid. + +Note: The schema path is resolved relative to the JSON file. If your JSON file is in another folder, adjust the relative path. + +If validation fails, jsonschema raises an exception that tells you the failing path and rule. + +Common mistakes to avoid +^^^^^^^^^^^^^^^^^^^^^^^^ + +- Missing item_type, name, or tags in an item. +- Using a table item without columns or rows. +- Using non-string values inside tag objects. +- Using a wrong structure for tree data (must include data.nodes). + diff --git a/doc/source/userguide/json_importer_models.rst b/doc/source/userguide/json_importer_models.rst new file mode 100644 index 0000000000..25b0779b49 --- /dev/null +++ b/doc/source/userguide/json_importer_models.rst @@ -0,0 +1,15 @@ +JSON Importer Models (Utils) +============================ + +``ansys.dynamicreporting.core.utils.json_importer_models`` is the shared +validation layer for JSON report-item payloads. + +- This is the place where Pydantic validation is defined for import payloads. +- It should be reused across server and serverless import paths. +- It exposes the ``ReportItemsModel`` entry point and item-type models used by + JSON importers. + +.. automodule:: ansys.dynamicreporting.core.utils.json_importer_models + :members: + :undoc-members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 4591b7bdde..9e9f66dd2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "django-weasyprint>=2.4.0,<2.5.0", "weasyprint>=67.0", "playwright>=1.58.0,<2.0.0", + "pydantic>=2.13.4", ] description = "Python interface to Ansys Dynamic Reporting" readme = "README.rst" diff --git a/src/ansys/dynamicreporting/core/serverless/adr.py b/src/ansys/dynamicreporting/core/serverless/adr.py index 1043a3f257..87722bee05 100644 --- a/src/ansys/dynamicreporting/core/serverless/adr.py +++ b/src/ansys/dynamicreporting/core/serverless/adr.py @@ -1103,6 +1103,29 @@ def load_templates(self, templates: dict) -> None: root_template.save() self._build_templates_from_parent(root_id_str, root_template, templates) + def import_json_items( + self, + json_file_path: str | Path, + ) -> Any: + """Import report items from a JSON payload into the current ADR database. + + Creates a default root template and saves all imported items to the database. + + Parameters + ---------- + json_file_path : str or Path + Path to the JSON payload file. + + Returns + ------- + Any + Parsed report-items returned by the JSON importer. + """ + from .json_importer import ServerlessReportItemImporter + + importer = ServerlessReportItemImporter(self) + return importer._import_json_items(json_file_path) + @staticmethod def get_report(**kwargs) -> Template: """Fetch a root report template (no parent) using template fields. diff --git a/src/ansys/dynamicreporting/core/serverless/json_importer.py b/src/ansys/dynamicreporting/core/serverless/json_importer.py new file mode 100644 index 0000000000..aea0ff6d19 --- /dev/null +++ b/src/ansys/dynamicreporting/core/serverless/json_importer.py @@ -0,0 +1,432 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +from ansys.dynamicreporting.core.serverless import ( + BasicLayout, + File, + Image, + Scene, + String, + Table, + Tree, +) +from ansys.dynamicreporting.core.adr_utils import get_logger +from ansys.dynamicreporting.core.utils.json_importer_models import ( + FileItemModel, + ImageItemModel, + ReportItemsModel, + SceneItemModel, + TableItemModel, + TextItemModel, + TreeItemModel, +) + + +class ServerlessReportItemImporter: + """Imports ADR report items from a JSON file in serverless mode. + + Reads a structured JSON file that describes report items (text, tables, + images, files, scenes, trees), creates a default ``BasicLayout`` template, + and persists all items to the configured ADR serverless instance. + """ + + def __init__(self, adr: Any) -> None: + """Initialize the serverless importer. + + Parameters + ---------- + adr : Any + Configured ADR serverless client instance used to create templates and items. + + Raises + ------ + ValueError + If ``adr`` is ``None``. + """ + if adr is None: + raise ValueError("ADR instance must be provided") + + self._adr = adr + self.template_tags: str = "" + self._logger = get_logger() + + def _normalize_tags(self, raw_tags: Any) -> str: + """Convert JSON-style tags to the string format expected by ADR.""" + if raw_tags is None: + return "" + + if isinstance(raw_tags, str): + return raw_tags + + values = [] + + if isinstance(raw_tags, dict): + raw_tags = [raw_tags] + + if isinstance(raw_tags, list): + for entry in raw_tags: + if isinstance(entry, dict): + for key, value in entry.items(): + if value is not None: + values.append(f"{key}={value}") + elif entry is not None: + values.append(str(entry)) + else: + values.append(str(raw_tags)) + + return " ".join(v for v in values if v) + + def _import_json_items(self, json_file_path: str | Path) -> ReportItemsModel: + """Load a JSON file, create a default template, and save all report items to ADR. + + Parameters + ---------- + json_file_path : str or Path + Path to the JSON file containing the serialized report items. + + Returns + ------- + ReportItemsModel + The parsed report items model loaded from the JSON file. + """ + report_items = ReportItemsModel.from_json_file(json_file_path) + self._logger.info( + "Loaded report items JSON: app_id=%s, total_items=%d", + report_items.app_id, + len(report_items.items), + ) + self.template_tags = self._normalize_tags(report_items.tags) + self._create_default_template(report_items.app_id) + self._save_items(report_items) + return report_items + + def _create_default_template(self, app_id: str) -> None: + """Create a simple root template used by imported report items.""" + + self._logger.info("Creating default template with tags: %s", self.template_tags) + template = self._adr.create_template( + BasicLayout, + name=app_id, + parent=None, + tags=self.template_tags, + ) + template.params = f'{{"HTML": "

{app_id}

"}}' + template.save() + + def _build_tags(self, item_tags: Any) -> str: + """Combine template tags and item tags into one ADR tag string.""" + parts = [self.template_tags, self._normalize_tags(item_tags)] + return " ".join(part for part in parts if part) + + def _flatten_properties(self, properties: Any) -> dict[str, Any]: + """Merge a list of property dicts into a single flat dict. + + The JSON schema represents item properties as an array of single-key + objects (e.g. ``[{"format": "floatdot0"}, {"ytitle": "Values"}]``). + This helper collapses that list into one dict so attributes can be set + with a single loop. + + Parameters + ---------- + properties : list or None + Raw ``properties`` value from the parsed item model. + + Returns + ------- + dict + Flat mapping of property name → value. Returns an empty dict + when *properties* is ``None`` or empty. + """ + if not properties: + return {} + flat: dict[str, Any] = {} + for entry in properties: + if isinstance(entry, dict): + flat.update(entry) + return flat + + def _apply_properties(self, adr_item: Any, properties: Any) -> None: + """Set item attributes from the JSON ``properties`` list before saving. + + Each entry in *properties* is a dict whose keys map directly to ADR + item attribute names (e.g. ``plot``, ``format``, ``xtitle``, + ``source``, ``sequence``). Attributes are set with :func:`setattr`; + for :class:`~ansys.dynamicreporting.core.serverless.item.Table` items + the values are automatically included in the pickled payload by + ``Table.save()``. Base item fields such as ``source`` and ``sequence`` + are persisted through the normal ORM save path. + + Parameters + ---------- + adr_item : Any + ADR item instance whose attributes are to be populated. + properties : list or None + Raw ``properties`` value from the parsed item model. + """ + flat = self._flatten_properties(properties) + for key, value in flat.items(): + if not isinstance(key, str) or key.startswith("_"): + continue + + # Guard against overriding actual methods. Check the class + # hierarchy rather than the instance so that MagicMock objects + # used in tests (whose instance attributes are all callable) are + # handled correctly. + cls_attr = getattr(type(adr_item), key, None) + if callable(cls_attr) and not isinstance(cls_attr, property): + self._logger.warning( + "Skipping property '%s' for item '%s' because it would override a method", + key, + getattr(adr_item, "name", ""), + ) + continue + + setattr(adr_item, key, value) + self._logger.debug( + "Applied property '%s' to item '%s'", + key, + getattr(adr_item, "name", ""), + ) + + def _save_item(self, item: Any) -> Any: + """Save one item to ADR based on item class name.""" + if isinstance(item, TextItemModel): + return self._save_text_item(item) + if isinstance(item, TableItemModel): + return self._save_table_item(item) + if isinstance(item, ImageItemModel): + return self._save_image_item(item) + if isinstance(item, FileItemModel): + return self._save_file_item(item) + if isinstance(item, SceneItemModel): + return self._save_scene_item(item) + if isinstance(item, TreeItemModel): + return self._save_tree_item(item) + + self._logger.warning("Unsupported item type skipped: %s", type(item).__name__) + return None + + def _create_and_save_item( + self, + item_type: Any, + *, + name: str, + content: Any, + tags: Any, + properties: Any = None, + save: bool = True, + ) -> Any: + """Create and persist an ADR item with normalized tags and properties. + + Parameters + ---------- + item_type : Any + ADR item class to instantiate (e.g. ``String``, ``Table``). + name : str + Item name. + content : Any + Primary payload for the item (type-dependent). + tags : Any + Raw tags from the JSON model; combined with template-level tags. + properties : list or None, optional + Raw ``properties`` list from the JSON model. Each entry is a + dict whose keys are ADR item attribute names. Attributes are set + before the item is saved. Defaults to ``None`` (no extra + properties applied). + save : bool, optional + When ``True`` (default) the item is saved immediately. Pass + ``False`` to defer saving so the caller can set additional + attributes (e.g. table column labels) before persisting. + + Returns + ------- + Any + The created ADR item instance. + """ + adr_item = self._adr.create_item( + item_type, + name=name, + content=content, + tags=self._build_tags(tags), + ) + self._apply_properties(adr_item, properties) + if save: + adr_item.save() + return adr_item + + def _save_text_item(self, item: Any) -> Any: + """Save text item as String.""" + text_item = self._create_and_save_item( + String, + name=item.name, + content=item.value, + tags=item.tags, + properties=item.properties, + ) + self._logger.info("Saved text item: %s", item.name) + return text_item + + def _save_table_item(self, item: Any) -> Any: + """Save table item.""" + # Convert rows to numpy array. TableContent accepts float ("f") or byte-string + # ("S") dtypes. Attempt numeric conversion first; fall back to bytes so that + # tables with textual or mixed-type columns are preserved rather than skipped. + try: + data = np.array(item.data.rows, dtype="float") + except (ValueError, TypeError): + data = np.array(item.data.rows, dtype="|S20") + + # Create table item with data + table_item = self._create_and_save_item( + Table, + name=item.name, + content=data, + tags=item.tags, + properties=item.properties, + save=False, + ) + + def _column_name(column): + if isinstance(column, str): + return column + return column.name + + # Set table metadata from columns + labels_row = [_column_name(col) for col in item.data.columns] + table_item.labels_row = labels_row + + # Set axis labels + if len(item.data.columns) > 0: + table_item.xaxis = _column_name(item.data.columns[0]) + + if len(item.data.columns) > 1: + table_item.yaxis = [_column_name(col) for col in item.data.columns[1:]] + + table_item.save() + self._logger.info("Saved table item: %s", item.name) + return table_item + + def _save_image_item(self, item: Any) -> Any: + """Save image item.""" + image_item = self._create_and_save_item( + Image, + name=item.name, + content=item.src, + tags=item.tags, + properties=item.properties, + ) + self._logger.info("Saved image item: %s", item.name) + return image_item + + def _save_file_item(self, item: Any) -> Any: + """Save file item.""" + file_item = self._create_and_save_item( + File, + name=item.name, + content=item.src, + tags=item.tags, + properties=item.properties, + ) + self._logger.info("Saved file item: %s", item.name) + return file_item + + def _save_scene_item(self, item: Any) -> Any: + """Save scene item.""" + scene_item = self._create_and_save_item( + Scene, + name=item.name, + content=item.src, + tags=item.tags, + properties=item.properties, + ) + self._logger.info("Saved scene item: %s", item.name) + return scene_item + + def _save_tree_item(self, item: Any) -> Any: + """Save tree item.""" + # Convert tree nodes to list structure + tree_data = [ + self._flatten_tree_node(node, f"node_{idx}") for idx, node in enumerate(item.data.nodes) + ] + + # Create tree item + tree_item = self._create_and_save_item( + Tree, + name=item.name, + content=tree_data, + tags=item.tags, + properties=item.properties, + ) + self._logger.info("Saved tree item: %s", item.name) + return tree_item + + def _flatten_tree_node(self, node: Any, parent_key: str = "", level: int = 0) -> dict[str, Any]: + """Flatten a tree node to a dictionary with required ``key`` and ``value`` fields. + + Parameters + ---------- + node : Any + Tree node model with ``name`` and ``children`` attributes. + parent_key : str, optional + Prefix used to build a unique key for this node, by default ``""``. + level : int, optional + Depth of this node in the tree, by default ``0``. + + Returns + ------- + dict + Dictionary with ``key``, ``value``, ``name``, ``level``, and + ``children`` fields, where ``children`` is a list of recursively + flattened child dictionaries. + """ + node_key = f"{parent_key}_{node.name}".replace(" ", "_").lower() + + node_dict = { + "key": node_key, + "value": node.name, + "name": node.name, + "level": level, + "children": [], + } + + # Recursively add children + for idx, child in enumerate(node.children): + child_key = f"{node_key}_child_{idx}" + node_dict["children"].append(self._flatten_tree_node(child, child_key, level + 1)) + + return node_dict + + def _save_items(self, data_items: Any) -> None: + """Save all items from ``data_items.items`` while continuing on item-level failures.""" + for item in data_items.items: + try: + self._save_item(item) + except Exception: + item_id = getattr(item, "id", "") + self._logger.exception("Error saving item %s", item_id) diff --git a/src/ansys/dynamicreporting/core/utils/json_importer_models.py b/src/ansys/dynamicreporting/core/utils/json_importer_models.py new file mode 100644 index 0000000000..b963834ef3 --- /dev/null +++ b/src/ansys/dynamicreporting/core/utils/json_importer_models.py @@ -0,0 +1,181 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Shared JSON report item schemas. + +This module should be used for JSON report item imports in both server and +serverless versions. It is the centralized place where Pydantic is used to +validate import payloads. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class ItemModel(BaseModel): + """Base JSON report item model.""" + + model_config = ConfigDict(extra="allow") + name: str + tags: List[Dict[str, Any]] + properties: Optional[List[Dict[str, Any]]] = None + + +class TableColumnModel(BaseModel): + """Table column model.""" + + name: str + type: str + + +class TableContentModel(BaseModel): + """Table content model containing columns and rows.""" + + model_config = ConfigDict(extra="allow") + columns: List[Union[TableColumnModel, str]] = Field(default_factory=list) + rows: List[List[Any]] + + +class TableItemModel(ItemModel): + """Table item model.""" + + item_type: Literal["table"] = "table" + data: TableContentModel + + @model_validator(mode="before") + @classmethod + def _validate_table_content_shape(cls, values: Any) -> Any: + """Support table items where columns and rows are at item top level.""" + if not isinstance(values, dict): + return values + + if "data" in values: + return values + + columns = values.get("columns") + rows = values.get("rows") + + if columns is not None and rows is not None: + values = dict(values) + values["data"] = { + "columns": columns, + "rows": rows, + } + + return values + + +class TextItemModel(ItemModel): + """Text item model.""" + + item_type: Literal["text"] = "text" + value: str + + +class ImageItemModel(ItemModel): + """Image item model.""" + + item_type: Literal["image"] = "image" + src: str + + +class FileItemModel(ItemModel): + """File item model.""" + + item_type: Literal["file"] = "file" + src: str + + +class SceneItemModel(ItemModel): + """Scene item model.""" + + item_type: Literal["scene"] = "scene" + src: str + + +class TreeNodeModel(BaseModel): + """Tree node model.""" + + model_config = ConfigDict(extra="allow") + name: str + children: List["TreeNodeModel"] = Field(default_factory=list) + + +class TreeContentModel(BaseModel): + """Tree content model.""" + + model_config = ConfigDict(extra="allow") + nodes: List[TreeNodeModel] + + +class TreeItemModel(ItemModel): + """Tree item model.""" + + item_type: Literal["tree"] = "tree" + data: TreeContentModel + + +TreeNodeModel.model_rebuild() + +ReportItemModel = Annotated[ + Union[ + TableItemModel, + TextItemModel, + ImageItemModel, + FileItemModel, + SceneItemModel, + TreeItemModel, + ], + Field(discriminator="item_type"), +] + + +class ReportItemsModel(BaseModel): + """Top-level JSON import payload.""" + + model_config = ConfigDict(extra="allow") + app_id: str + tags: List[Dict[str, Any]] + items: List[ReportItemModel] + + @classmethod + def from_json_file(cls, file_path: str | Path) -> "ReportItemsModel": + """Load and validate JSON report items via Pydantic.""" + with Path(file_path).open("r", encoding="utf-8") as stream: + raw_payload = json.load(stream) + return cls.model_validate(raw_payload) + + +__all__ = [ + "FileItemModel", + "ImageItemModel", + "SceneItemModel", + "TableItemModel", + "TextItemModel", + "TreeItemModel", + "ReportItemsModel", +] diff --git a/tests/serverless/test_adr.py b/tests/serverless/test_adr.py index 534321a23f..7d879a5383 100644 --- a/tests/serverless/test_adr.py +++ b/tests/serverless/test_adr.py @@ -1471,6 +1471,30 @@ def test_load_templates_from_file_no_such_file(adr_serverless): adr_serverless.load_templates_from_file("nonexistent.json") +@pytest.mark.ado_test +def test_import_json_items_delegates_to_serverless_importer(adr_serverless, monkeypatch, tmp_path): + from ansys.dynamicreporting.core.serverless import json_importer as json_importer_module + + calls = {} + + class FakeImporter: + def __init__(self, adr): + calls["adr"] = adr + + def _import_json_items(self, json_file_path): + calls["json_file_path"] = json_file_path + return {"imported": True} + + monkeypatch.setattr(json_importer_module, "ServerlessReportItemImporter", FakeImporter) + + json_path = tmp_path / "report_items.json" + result = adr_serverless.import_json_items(json_path) + + assert result == {"imported": True} + assert calls["adr"] is adr_serverless + assert calls["json_file_path"] == json_path + + @pytest.mark.ado_test def test_render_report_as_browser_pdf_success(adr_serverless, monkeypatch): from ansys.dynamicreporting.core.serverless import BasicLayout diff --git a/tests/serverless/test_json_importer.py b/tests/serverless/test_json_importer.py new file mode 100644 index 0000000000..f90b468030 --- /dev/null +++ b/tests/serverless/test_json_importer.py @@ -0,0 +1,789 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import numpy as np +import pytest + +from ansys.dynamicreporting.core.serverless.json_importer import ServerlessReportItemImporter +from ansys.dynamicreporting.core.utils.json_importer_models import ( + FileItemModel, + ImageItemModel, + SceneItemModel, + TableItemModel, + TextItemModel, + TreeItemModel, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_importer(adr=None): + """Return an importer backed by a fresh MagicMock ADR if none is provided.""" + if adr is None: + adr = MagicMock() + return ServerlessReportItemImporter(adr) + + +def _mock_adr_item(): + """Return a MagicMock that quacks like an ADR item (has .save()).""" + item = MagicMock() + item.save = MagicMock() + return item + + +# --------------------------------------------------------------------------- +# __init__ +# --------------------------------------------------------------------------- + + +def test_init_raises_value_error_when_adr_is_none(): + with pytest.raises(ValueError, match="ADR instance must be provided"): + ServerlessReportItemImporter(None) + + +def test_init_stores_adr_instance(): + adr = MagicMock() + importer = ServerlessReportItemImporter(adr) + assert importer._adr is adr + + +def test_init_sets_empty_template_tags(): + importer = _make_importer() + assert importer.template_tags == "" + + +# --------------------------------------------------------------------------- +# _normalize_tags +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw_tags, expected", + [ + (None, ""), + ("already a string", "already a string"), + ({"key": "val"}, "key=val"), + ({"key": None}, ""), # None values are skipped + ([{"a": "1"}, {"b": "2"}], "a=1 b=2"), + (["foo", "bar"], "foo bar"), + (["foo", None, "bar"], "foo bar"), # None list entries are skipped + ([{"k": "v"}, "plain"], "k=v plain"), # mixed dict and string entries + ([], ""), + ], +) +def test_normalize_tags(raw_tags, expected): + importer = _make_importer() + assert importer._normalize_tags(raw_tags) == expected + + +def test_normalize_tags_dict_with_multiple_keys(): + importer = _make_importer() + # Dict with two keys: both should appear in the output (order may vary in older Python, + # but dicts preserve insertion order since 3.7) + result = importer._normalize_tags({"section": "A", "report": "B"}) + assert "section=A" in result + assert "report=B" in result + + +# --------------------------------------------------------------------------- +# _build_tags +# --------------------------------------------------------------------------- + + +def test_build_tags_combines_template_and_item_tags(): + importer = _make_importer() + importer.template_tags = "tmpl=1" + result = importer._build_tags([{"item": "2"}]) + assert result == "tmpl=1 item=2" + + +def test_build_tags_returns_only_item_tags_when_template_empty(): + importer = _make_importer() + importer.template_tags = "" + result = importer._build_tags([{"item": "2"}]) + assert result == "item=2" + + +def test_build_tags_returns_only_template_tags_when_item_tags_empty(): + importer = _make_importer() + importer.template_tags = "tmpl=1" + result = importer._build_tags(None) + assert result == "tmpl=1" + + +def test_build_tags_returns_empty_string_when_both_empty(): + importer = _make_importer() + importer.template_tags = "" + result = importer._build_tags(None) + assert result == "" + + +# --------------------------------------------------------------------------- +# _create_default_template +# --------------------------------------------------------------------------- + + +def test_create_default_template_calls_adr_and_saves(): + adr = MagicMock() + mock_template = MagicMock() + adr.create_template.return_value = mock_template + importer = ServerlessReportItemImporter(adr) + importer.template_tags = "report=x" + + importer._create_default_template("my_app") + + adr.create_template.assert_called_once() + assert mock_template.params == '{"HTML": "

my_app

"}' + mock_template.save.assert_called_once() + + +# --------------------------------------------------------------------------- +# _create_and_save_item +# --------------------------------------------------------------------------- + + +def test_create_and_save_item_calls_save_by_default(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + result = importer._create_and_save_item(MagicMock(), name="n", content="c", tags=[]) + + mock_item.save.assert_called_once() + assert result is mock_item + + +def test_create_and_save_item_skips_save_when_flag_is_false(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + importer._create_and_save_item(MagicMock(), name="n", content="c", tags=[], save=False) + + mock_item.save.assert_not_called() + + +def test_create_and_save_item_passes_normalized_tags_to_adr(): + adr = MagicMock() + adr.create_item.return_value = _mock_adr_item() + importer = _make_importer(adr) + importer.template_tags = "t=1" + + importer._create_and_save_item(MagicMock(), name="n", content="c", tags=[{"i": "2"}]) + + _, kwargs = adr.create_item.call_args + assert kwargs["tags"] == "t=1 i=2" + + +# --------------------------------------------------------------------------- +# _save_item dispatch +# --------------------------------------------------------------------------- + + +def test_save_item_routes_text_item(): + importer = _make_importer() + item = TextItemModel(name="T", tags=[], value="hello") + with patch.object(importer, "_save_text_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_routes_table_item(): + importer = _make_importer() + item = TableItemModel(name="T", tags=[], data={"columns": ["X"], "rows": [[1.0]]}) + with patch.object(importer, "_save_table_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_routes_image_item(): + importer = _make_importer() + item = ImageItemModel(name="I", tags=[], src="/img.png") + with patch.object(importer, "_save_image_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_routes_file_item(): + importer = _make_importer() + item = FileItemModel(name="F", tags=[], src="/data.csv") + with patch.object(importer, "_save_file_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_routes_scene_item(): + importer = _make_importer() + item = SceneItemModel(name="S", tags=[], src="/scene.avz") + with patch.object(importer, "_save_scene_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_routes_tree_item(): + importer = _make_importer() + item = TreeItemModel(name="Tr", tags=[], data={"nodes": [{"name": "Root"}]}) + with patch.object(importer, "_save_tree_item", return_value=MagicMock()) as mock: + importer._save_item(item) + mock.assert_called_once_with(item) + + +def test_save_item_logs_warning_and_returns_none_for_unsupported_type(): + importer = _make_importer() + unsupported = SimpleNamespace() # not any of the known model types + + with patch.object(importer._logger, "warning") as mock_warn: + result = importer._save_item(unsupported) + + assert result is None + mock_warn.assert_called_once() + assert "Unsupported" in mock_warn.call_args.args[0] + + +# --------------------------------------------------------------------------- +# _save_text_item +# --------------------------------------------------------------------------- + + +def test_save_text_item_creates_string_item_with_correct_args(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TextItemModel(name="Msg", tags=[], value="hello world") + result = importer._save_text_item(item) + + _, kwargs = adr.create_item.call_args + assert kwargs["name"] == "Msg" + assert kwargs["content"] == "hello world" + mock_item.save.assert_called_once() + assert result is mock_item + + +# --------------------------------------------------------------------------- +# _save_table_item +# --------------------------------------------------------------------------- + + +def test_save_table_item_converts_rows_to_numpy_array(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel( + name="T", tags=[], data={"columns": ["X", "Y"], "rows": [[1.0, 2.0], [3.0, 4.0]]} + ) + importer._save_table_item(item) + + _, kwargs = adr.create_item.call_args + assert isinstance(kwargs["content"], np.ndarray) + np.testing.assert_array_equal(kwargs["content"], [[1.0, 2.0], [3.0, 4.0]]) + + +def test_save_table_item_sets_labels_from_string_columns(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel(name="T", tags=[], data={"columns": ["X", "Y", "Z"], "rows": [[1, 2, 3]]}) + importer._save_table_item(item) + + assert mock_item.labels_row == ["X", "Y", "Z"] + assert mock_item.xaxis == "X" + assert mock_item.yaxis == ["Y", "Z"] + + +def test_save_table_item_sets_labels_from_object_columns(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel( + name="T", + tags=[], + data={ + "columns": [{"name": "Time", "type": "float"}, {"name": "Pressure", "type": "float"}], + "rows": [[0.0, 101325.0]], + }, + ) + importer._save_table_item(item) + + assert mock_item.labels_row == ["Time", "Pressure"] + assert mock_item.xaxis == "Time" + assert mock_item.yaxis == ["Pressure"] + + +def test_save_table_item_with_single_column_sets_only_xaxis(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel(name="T", tags=[], data={"columns": ["X"], "rows": [[1.0]]}) + importer._save_table_item(item) + + assert mock_item.labels_row == ["X"] + assert mock_item.xaxis == "X" + # yaxis is only set when there are 2+ columns; with a single column it is never + # assigned, so accessing the attribute returns a raw MagicMock, not a list. + assert not isinstance(mock_item.yaxis, list) + + +def test_save_table_item_calls_save_at_end(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel(name="T", tags=[], data={"columns": ["X"], "rows": [[1.0]]}) + importer._save_table_item(item) + + mock_item.save.assert_called_once() + + +def test_save_table_item_falls_back_to_bytes_dtype_for_string_columns(): + """Tables with non-numeric cells must be stored as a byte-string array, not skipped.""" + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel( + name="T", + tags=[], + data={ + "columns": ["Label", "Value"], + "rows": [["alpha", "1.0"], ["beta", "2.0"]], + }, + ) + importer._save_table_item(item) + + _, kwargs = adr.create_item.call_args + result = kwargs["content"] + assert isinstance(result, np.ndarray) + # TableContent accepts dtype kind "S" (byte strings) for non-numeric data + assert result.dtype.kind == "S" + assert result.shape == (2, 2) + + +# --------------------------------------------------------------------------- +# _save_image_item / _save_file_item / _save_scene_item +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_cls, src_field, importer_method", + [ + (ImageItemModel, "/img.png", "_save_image_item"), + (FileItemModel, "/data.csv", "_save_file_item"), + (SceneItemModel, "/model.avz", "_save_scene_item"), + ], +) +def test_save_src_based_item_passes_src_as_content(model_cls, src_field, importer_method): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = model_cls(name="Item", tags=[], src=src_field) + result = getattr(importer, importer_method)(item) + + _, kwargs = adr.create_item.call_args + assert kwargs["name"] == "Item" + assert kwargs["content"] == src_field + mock_item.save.assert_called_once() + assert result is mock_item + + +# --------------------------------------------------------------------------- +# _flatten_tree_node +# --------------------------------------------------------------------------- + + +def test_flatten_tree_node_leaf_has_correct_structure(): + importer = _make_importer() + node = SimpleNamespace(name="Root", children=[]) + + result = importer._flatten_tree_node(node, "pk", 0) + + assert result["key"] == "pk_root" + assert result["value"] == "Root" + assert result["name"] == "Root" + assert result["level"] == 0 + assert result["children"] == [] + + +def test_flatten_tree_node_normalizes_spaces_in_key(): + importer = _make_importer() + node = SimpleNamespace(name="My Node", children=[]) + + result = importer._flatten_tree_node(node, "parent key", 1) + + assert " " not in result["key"] + assert result["key"] == "parent_key_my_node" + + +def test_flatten_tree_node_key_is_lowercased(): + importer = _make_importer() + node = SimpleNamespace(name="UPPER", children=[]) + + result = importer._flatten_tree_node(node, "PREFIX", 0) + + assert result["key"] == "prefix_upper" + + +def test_flatten_tree_node_increments_level_for_children(): + importer = _make_importer() + grandchild = SimpleNamespace(name="GC", children=[]) + child = SimpleNamespace(name="Child", children=[grandchild]) + root = SimpleNamespace(name="Root", children=[child]) + + result = importer._flatten_tree_node(root, "pk", 0) + + assert result["level"] == 0 + child_result = result["children"][0] + assert child_result["level"] == 1 + grandchild_result = child_result["children"][0] + assert grandchild_result["level"] == 2 + + +def test_flatten_tree_node_preserves_multiple_children(): + importer = _make_importer() + children = [SimpleNamespace(name=f"C{i}", children=[]) for i in range(3)] + root = SimpleNamespace(name="Root", children=children) + + result = importer._flatten_tree_node(root, "pk", 0) + + assert len(result["children"]) == 3 + assert result["children"][0]["value"] == "C0" + assert result["children"][2]["value"] == "C2" + + +# --------------------------------------------------------------------------- +# _save_tree_item +# --------------------------------------------------------------------------- + + +def test_save_tree_item_flattens_nodes_and_saves(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TreeItemModel( + name="Tree", + tags=[], + data={"nodes": [{"name": "Root", "children": [{"name": "Leaf"}]}]}, + ) + result = importer._save_tree_item(item) + + _, kwargs = adr.create_item.call_args + assert kwargs["name"] == "Tree" + tree_content = kwargs["content"] + assert isinstance(tree_content, list) + assert len(tree_content) == 1 # one root node + assert tree_content[0]["value"] == "Root" + assert len(tree_content[0]["children"]) == 1 + assert tree_content[0]["children"][0]["value"] == "Leaf" + mock_item.save.assert_called_once() + assert result is mock_item + + +# --------------------------------------------------------------------------- +# _save_items +# --------------------------------------------------------------------------- + + +def test_save_items_calls_save_item_for_each(): + importer = _make_importer() + items_data = MagicMock() + items_data.items = [ + TextItemModel(name="A", tags=[], value="v1"), + TextItemModel(name="B", tags=[], value="v2"), + ] + with patch.object(importer, "_save_item") as mock_save: + importer._save_items(items_data) + assert mock_save.call_count == 2 + + +def test_save_items_continues_and_logs_on_item_failure(): + importer = _make_importer() + good = TextItemModel(name="Good", tags=[], value="ok") + bad = TextItemModel(name="Bad", tags=[], value="fail") + + def _side_effect(item): + if item.name == "Bad": + raise RuntimeError("boom") + + items_data = MagicMock() + items_data.items = [good, bad, good] + + with patch.object(importer, "_save_item", side_effect=_side_effect): + with patch.object(importer._logger, "exception") as mock_exc: + # should not raise + importer._save_items(items_data) + + mock_exc.assert_called_once() + + +# --------------------------------------------------------------------------- +# _import_json_items — end-to-end with mocked ADR +# --------------------------------------------------------------------------- + + +def test_import_json_items_happy_path(tmp_path): + payload = { + "app_id": "test-app", + "tags": [{"report": "json_import"}], + "items": [ + {"item_type": "text", "name": "Summary", "tags": [], "value": "All good."}, + { + "item_type": "table", + "name": "Results", + "tags": [], + "columns": ["X", "Y"], + "rows": [[1.0, 2.0], [3.0, 4.0]], + }, + ], + } + json_file = tmp_path / "report.json" + json_file.write_text(json.dumps(payload), encoding="utf-8") + + adr = MagicMock() + mock_template = MagicMock() + mock_adr_item = _mock_adr_item() + adr.create_template.return_value = mock_template + adr.create_item.return_value = mock_adr_item + + importer = _make_importer(adr) + result = importer._import_json_items(json_file) + + # Returns the parsed model + assert result.app_id == "test-app" + assert len(result.items) == 2 + + # Template was created once + adr.create_template.assert_called_once() + mock_template.save.assert_called_once() + assert mock_template.params == '{"HTML": "

test-app

"}' + + # Template tags were derived from top-level tags + assert importer.template_tags == "report=json_import" + + # Both items were saved (text calls save once; table calls save once) + assert adr.create_item.call_count == 2 + + +def test_import_json_items_sets_template_tags_from_payload(tmp_path): + payload = { + "app_id": "app", + "tags": [{"env": "prod", "version": "2"}], + "items": [], + } + json_file = tmp_path / "r.json" + json_file.write_text(json.dumps(payload), encoding="utf-8") + + adr = MagicMock() + adr.create_template.return_value = MagicMock() + importer = _make_importer(adr) + importer._import_json_items(json_file) + + assert "env=prod" in importer.template_tags + assert "version=2" in importer.template_tags + + +# --------------------------------------------------------------------------- +# _flatten_properties +# --------------------------------------------------------------------------- + + +def test_flatten_properties_returns_empty_dict_for_none(): + importer = _make_importer() + assert importer._flatten_properties(None) == {} + + +def test_flatten_properties_returns_empty_dict_for_empty_list(): + importer = _make_importer() + assert importer._flatten_properties([]) == {} + + +def test_flatten_properties_merges_list_of_single_key_dicts(): + importer = _make_importer() + result = importer._flatten_properties([{"format": "floatdot0"}, {"ytitle": "Values"}]) + assert result == {"format": "floatdot0", "ytitle": "Values"} + + +def test_flatten_properties_merges_multi_key_dict_entries(): + importer = _make_importer() + result = importer._flatten_properties([{"xaxis": "X", "yaxis": ["Y1", "Y2"]}]) + assert result == {"xaxis": "X", "yaxis": ["Y1", "Y2"]} + + +def test_flatten_properties_later_entries_overwrite_earlier_ones(): + importer = _make_importer() + result = importer._flatten_properties([{"plot": "bar"}, {"plot": "line"}]) + assert result["plot"] == "line" + + +def test_flatten_properties_skips_non_dict_entries(): + importer = _make_importer() + # Non-dict entries in the list should not raise and should be ignored + result = importer._flatten_properties([{"format": "floatdot0"}, "unexpected_string"]) + assert result == {"format": "floatdot0"} + + +# --------------------------------------------------------------------------- +# _apply_properties +# --------------------------------------------------------------------------- + + +def test_apply_properties_sets_attributes_on_item(): + importer = _make_importer() + mock_item = MagicMock() + mock_item.name = "MyItem" + + importer._apply_properties(mock_item, [{"plot": "line"}, {"xtitle": "Time (s)"}]) + + assert mock_item.plot == "line" + assert mock_item.xtitle == "Time (s)" + + +def test_apply_properties_does_nothing_for_none(): + importer = _make_importer() + mock_item = MagicMock() + + importer._apply_properties(mock_item, None) + + # No setattr calls beyond mock setup + mock_item.assert_not_called() + + +def test_apply_properties_does_nothing_for_empty_list(): + importer = _make_importer() + mock_item = MagicMock() + + importer._apply_properties(mock_item, []) + + mock_item.assert_not_called() + + +def test_apply_properties_sets_source_and_sequence(): + importer = _make_importer() + mock_item = MagicMock() + mock_item.name = "I" + + importer._apply_properties(mock_item, [{"source": "Simulation 1"}, {"sequence": 3}]) + + assert mock_item.source == "Simulation 1" + assert mock_item.sequence == 3 + + +# --------------------------------------------------------------------------- +# _create_and_save_item — properties path +# --------------------------------------------------------------------------- + + +def test_create_and_save_item_applies_properties_before_save(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + importer._create_and_save_item( + MagicMock(), + name="n", + content="c", + tags=[], + properties=[{"plot": "bar"}, {"xtitle": "Time"}], + ) + + # Properties should be set before save() is called + assert mock_item.plot == "bar" + assert mock_item.xtitle == "Time" + mock_item.save.assert_called_once() + + +def test_create_and_save_item_with_no_properties_does_not_raise(): + adr = MagicMock() + adr.create_item.return_value = _mock_adr_item() + importer = _make_importer(adr) + + # Should not raise when properties is None (the default) + importer._create_and_save_item(MagicMock(), name="n", content="c", tags=[]) + + +# --------------------------------------------------------------------------- +# _save_table_item — properties applied (plot, xtitle, ytitle, etc.) +# --------------------------------------------------------------------------- + + +def test_save_table_item_applies_properties_from_model(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TableItemModel( + name="T", + tags=[], + properties=[{"plot": "line"}, {"xtitle": "X Axis"}, {"ytitle": "Y Axis"}], + data={"columns": ["X", "Y"], "rows": [[1.0, 2.0]]}, + ) + importer._save_table_item(item) + + assert mock_item.plot == "line" + assert mock_item.xtitle == "X Axis" + assert mock_item.ytitle == "Y Axis" + mock_item.save.assert_called_once() + + +def test_save_text_item_applies_properties_from_model(): + adr = MagicMock() + mock_item = _mock_adr_item() + adr.create_item.return_value = mock_item + importer = _make_importer(adr) + + item = TextItemModel( + name="T", + tags=[], + properties=[{"source": "Pipeline A"}, {"sequence": 5}], + value="hello", + ) + importer._save_text_item(item) + + assert mock_item.source == "Pipeline A" + assert mock_item.sequence == 5 diff --git a/tests/serverless/test_json_importer_models.py b/tests/serverless/test_json_importer_models.py new file mode 100644 index 0000000000..d298e8d3c5 --- /dev/null +++ b/tests/serverless/test_json_importer_models.py @@ -0,0 +1,398 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json + +import pytest +from pydantic import ValidationError + +from ansys.dynamicreporting.core.utils.json_importer_models import ( + ReportItemsModel, + TableColumnModel, + TableItemModel, + TextItemModel, + TreeItemModel, +) + + +def test_report_items_model_supports_table_rows_and_columns_at_top_level(tmp_path): + payload = { + "app_id": "app-1", + "tags": [], + "items": [ + { + "item_type": "table", + "name": "Table A", + "tags": [], + "columns": [ + {"name": "time", "type": "float", "unit": "s"}, + "value", + ], + "rows": [[0.0, 1.1], [1.0, 2.2]], + } + ], + } + data_file = tmp_path / "report_items.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.app_id == "app-1" + assert model.items[0].item_type == "table" + assert model.items[0].data.rows == [[0.0, 1.1], [1.0, 2.2]] + assert model.items[0].data.columns[0].name == "time" + + +def test_report_items_model_supports_discriminated_text_items(tmp_path): + payload = { + "app_id": "app-2", + "tags": [], + "items": [ + { + "item_type": "text", + "name": "Message", + "tags": [], + "value": "hello", + } + ], + } + data_file = tmp_path / "report_items_text.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.items[0].item_type == "text" + assert model.items[0].value == "hello" + + +def test_report_items_model_allows_partial_image_metadata(tmp_path): + payload = { + "app_id": "app-3", + "tags": [], + "items": [ + { + "item_type": "image", + "name": "Plot", + "tags": [], + "src": "C://tmp/plot.png", + "data": {"format": "png"}, + } + ], + } + data_file = tmp_path / "report_items_image.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.items[0].item_type == "image" + assert model.items[0].src == "C://tmp/plot.png" + + +def test_report_items_model_allows_scene_metadata_as_extra_payload(tmp_path): + payload = { + "app_id": "app-4", + "tags": [], + "items": [ + { + "item_type": "scene", + "name": "Assembly", + "tags": [], + "src": "C://tmp/assembly.avz", + "data": {"format": "avz", "description": "assembly scene"}, + } + ], + } + data_file = tmp_path / "report_items_scene.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.items[0].item_type == "scene" + assert model.items[0].src == "C://tmp/assembly.avz" + + +def test_report_items_model_validates_full_sample_payload(tmp_path): + payload = { + "app_id": "adr-mechanical", + "tags": [{"report": "template_json_import"}], + "items": [ + { + "item_type": "text", + "name": "Summary", + "value": "Simulation completed successfully.", + "tags": [{"section": "child_json_import"}], + }, + { + "item_type": "text", + "name": "Formatted Report HTML", + "value": "

Report

All results are within range.

", + "tags": [{"section": "child_json_import"}], + }, + { + "item_type": "image", + "name": "Result Plot", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/image.png", + "tags": [{"section": "child_json_import"}], + }, + { + "item_type": "file", + "name": "Raw Data File", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/results.csv", + "tags": [{"section": "child_json_import"}], + }, + { + "item_type": "scene", + "name": "3D Scene", + "src": "C://ANSYSDev/repos/adr_mechanical/assets/scene.avz", + "tags": [{"section": "child_json_import"}], + }, + { + "item_type": "table", + "name": "Temperature Table One", + "tags": [{"section": "child_json_import"}], + "columns": [], + "rows": [ + [0.5, 10, 101325], + [0.5, 12, 101300], + [1, 15, 101280], + ], + }, + { + "item_type": "table", + "name": "Temperature Table One", + "tags": [{"section": "child_json_import"}], + "columns": ["X", "Sin", "Cos"], + "rows": [[1, 2], [3, 4]], + "plot": "line", + "xaxis": "X", + "yaxis": ["Sin", "Cos"], + "properties": [ + {"format": "floatdot0"}, + {"zaxis": "Z"}, + {"xaxis_format": "floatdot0"}, + {"ytitle": "Values"}, + {"ytitle": "X"}, + ], + }, + { + "item_type": "tree", + "name": "Hierarchy", + "tags": [{"section": "child_json_import"}], + "data": { + "nodes": [ + { + "name": "Parent-Root", + "children": [ + { + "name": "Child One", + "children": [{"name": "Child One One"}], + }, + { + "name": "Child Two", + "children": [ + { + "name": "Grandchild One", + "children": [{"name": "Child Two Two"}], + } + ], + }, + ], + } + ] + }, + }, + ], + } + data_file = tmp_path / "report_items_sample.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.app_id == "adr-mechanical" + assert model.tags == [{"report": "template_json_import"}] + assert len(model.items) == 8 + assert [item.item_type for item in model.items] == [ + "text", + "text", + "image", + "file", + "scene", + "table", + "table", + "tree", + ] + assert model.items[0].value == "Simulation completed successfully." + assert model.items[2].src.endswith("assets/image.png") + assert model.items[5].data.rows[2] == [1, 15, 101280] + assert model.items[6].data.columns == ["X", "Sin", "Cos"] + assert model.items[7].data.nodes[0].children[1].children[0].name == "Grandchild One" + + +def test_report_items_model_raises_for_unknown_item_type(tmp_path): + payload = { + "app_id": "adr-mechanical", + "items": [{"item_type": "unknown", "name": "Bad"}], + } + data_file = tmp_path / "report_items_invalid.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValidationError): + ReportItemsModel.from_json_file(data_file) + + +# --------------------------------------------------------------------------- +# from_json_file — error handling +# --------------------------------------------------------------------------- + + +def test_from_json_file_raises_for_missing_file(tmp_path): + missing = tmp_path / "does_not_exist.json" + with pytest.raises(FileNotFoundError): + ReportItemsModel.from_json_file(missing) + + +def test_from_json_file_raises_for_malformed_json(tmp_path): + bad_file = tmp_path / "bad.json" + bad_file.write_text("{not valid json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + ReportItemsModel.from_json_file(bad_file) + + +# --------------------------------------------------------------------------- +# TableColumnModel +# --------------------------------------------------------------------------- + + +def test_table_column_model_stores_name_and_type(): + col = TableColumnModel(name="Velocity", type="float") + assert col.name == "Velocity" + assert col.type == "float" + + +def test_table_column_model_missing_name_raises(): + with pytest.raises(ValidationError): + TableColumnModel(type="float") + + +def test_table_column_model_missing_type_raises(): + with pytest.raises(ValidationError): + TableColumnModel(name="X") + + +# --------------------------------------------------------------------------- +# TableItemModel — model validator +# --------------------------------------------------------------------------- + + +def test_table_item_model_wraps_columns_and_rows_into_data(): + item = TableItemModel( + name="T", + tags=[], + columns=["A", "B"], + rows=[[1.0, 2.0]], + ) + assert item.data.columns == ["A", "B"] + assert item.data.rows == [[1.0, 2.0]] + + +def test_table_item_model_does_not_overwrite_explicit_data(): + # When 'data' is already provided, the validator should leave it untouched + # even if top-level 'columns'/'rows' are also present. + item = TableItemModel( + name="T", + tags=[], + data={"columns": ["X"], "rows": [[9.0]]}, + columns=["should", "be", "ignored"], + rows=[[0.0, 0.0, 0.0]], + ) + assert item.data.columns == ["X"] + assert item.data.rows == [[9.0]] + + +# --------------------------------------------------------------------------- +# TextItemModel +# --------------------------------------------------------------------------- + + +def test_text_item_model_missing_value_raises(): + with pytest.raises(ValidationError): + TextItemModel(name="T", tags=[]) + + +# --------------------------------------------------------------------------- +# TreeItemModel — leaf nodes +# --------------------------------------------------------------------------- + + +def test_tree_node_leaf_has_empty_children_by_default(tmp_path): + payload = { + "app_id": "app", + "tags": [], + "items": [ + { + "item_type": "tree", + "name": "T", + "tags": [], + "data": {"nodes": [{"name": "LeafNode"}]}, + } + ], + } + data_file = tmp_path / "tree_leaf.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + leaf = model.items[0].data.nodes[0] + + assert leaf.name == "LeafNode" + assert leaf.children == [] + + +# --------------------------------------------------------------------------- +# ReportItemsModel — edge cases +# --------------------------------------------------------------------------- + + +def test_report_items_model_with_empty_items_list(tmp_path): + payload = {"app_id": "empty-app", "tags": [], "items": []} + data_file = tmp_path / "empty.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + model = ReportItemsModel.from_json_file(data_file) + + assert model.app_id == "empty-app" + assert model.items == [] + + +def test_report_items_model_allows_extra_top_level_fields(tmp_path): + payload = { + "app_id": "app", + "tags": [], + "items": [], + "custom_field": "extra", + "version": 42, + } + data_file = tmp_path / "extra.json" + data_file.write_text(json.dumps(payload), encoding="utf-8") + + # Should not raise; extra fields are allowed by the model config + model = ReportItemsModel.from_json_file(data_file) + assert model.app_id == "app"