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"