"""Cloud resource for interactive Flexcompute Flow reports."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any
import pydantic as pd
from pydantic.alias_generators import to_camel
from ._flow360_adapter import (
DEFAULT_FLOW360_ADAPTER,
DEFAULT_ROOT_FOLDER_ID,
Flow360Adapter,
)
from .apply_to_new import build_apply_to_new_report_config
from .models import ReportAssociatedResource, ReportConfig, ReportInfo, ResourceType
from .resource_types import CaseResourceSelector, ReportResourceInput
from .scene import Scene
from .spec import (
ReportCamera,
ReportChart,
ReportChartVariable,
ReportSectionSpec,
ReportSummary,
ReportVisualization,
ReportVisualizationSetup,
ReportVisualizationView,
build_report_config,
)
class _CreateReportRequest(pd.BaseModel):
model_config = pd.ConfigDict(extra="forbid", populate_by_name=True, alias_generator=to_camel)
name: str
description: str
tags: list[str]
parent_folder_id: str
config_json: str
def to_dict(self) -> dict:
"""Return the request payload with API aliases."""
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
class _ReportResourceRef(pd.BaseModel):
model_config = pd.ConfigDict(extra="forbid")
id: str
type: ResourceType
class _UpdateReportRequest(pd.BaseModel):
model_config = pd.ConfigDict(extra="forbid", populate_by_name=True, alias_generator=to_camel)
name: str | None = None
description: str | None = None
tags: list[str] | None = None
parent_folder_id: str | None = None
associated_resources: list[_ReportResourceRef] | None = None
config_json: str | None = None
def to_dict(self) -> dict:
"""Return only explicitly updated fields with API aliases."""
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
def _clean_tags(tags: Iterable[str] | None) -> list[str]:
"""Trim and deduplicate report tags while preserving order."""
if isinstance(tags, str):
raise TypeError("tags must be an iterable of strings, not a string")
cleaned: list[str] = []
seen: set[str] = set()
for tag in tags or []:
if not isinstance(tag, str):
raise TypeError("tags must contain only strings")
normalized = tag.strip()
if normalized and normalized not in seen:
cleaned.append(normalized)
seen.add(normalized)
return cleaned
[docs]
class Report:
"""An interactive Report resource created on the Flow360 platform."""
Camera = ReportCamera
Chart = ReportChart
ChartVariable = ReportChartVariable
Summary = ReportSummary
Visualization = ReportVisualization
VisualizationSetup = ReportVisualizationSetup
VisualizationView = ReportVisualizationView
Scene = Scene
_cloud_resource_type_name = "Report"
_adapter = DEFAULT_FLOW360_ADAPTER
def __init__(
self,
*,
info: ReportInfo,
environment: Any,
config: ReportConfig,
adapter: Flow360Adapter,
):
self._info = info
self._environment = environment
self._config = config
self._flow360_adapter = adapter
@classmethod
def _create_from_config( # pylint: disable=too-many-arguments
cls,
*,
name: str,
config: ReportConfig,
description: str,
tags: Iterable[str] | None,
parent_folder_id: str,
environment: Any,
adapter: Flow360Adapter,
) -> "Report":
"""Create a Report from an SDK-managed persisted configuration."""
normalized_name = name.strip()
normalized_parent_folder_id = parent_folder_id.strip()
if not normalized_name:
raise ValueError("name must not be empty")
if not normalized_parent_folder_id:
raise ValueError("parent_folder_id must not be empty")
request = _CreateReportRequest(
name=normalized_name,
description=description.strip(),
tags=_clean_tags(tags),
parent_folder_id=normalized_parent_folder_id,
config_json=config.to_json(),
)
response = adapter.create_report(request.to_dict(), environment=environment)
info = ReportInfo.model_validate(response)
return cls(info=info, environment=environment, config=config, adapter=adapter)
[docs]
@classmethod
def from_cloud(cls, report_id: str) -> "Report":
"""Load an interactive Report from the active Flow360 environment."""
if not isinstance(report_id, str):
raise TypeError("report_id must be a string")
normalized_report_id = report_id.strip()
if not normalized_report_id:
raise ValueError("report_id must not be empty")
adapter = cls._adapter
environment = adapter.current_environment()
response = adapter.get_report(normalized_report_id, environment=environment)
return cls._from_cloud_response(
response=response,
environment=environment,
adapter=adapter,
report_id=normalized_report_id,
)
@classmethod
def _from_cloud_response(
cls,
*,
response: Mapping[str, Any],
environment: Any,
adapter: Flow360Adapter,
report_id: str,
) -> "Report":
"""Build a Report from a cloud response that includes persisted config."""
raw_config = response.get("configJson")
if isinstance(raw_config, str):
config = ReportConfig.model_validate_json(raw_config)
elif isinstance(raw_config, Mapping):
config = ReportConfig.model_validate(raw_config)
response = {**response, "configJson": config.to_json()}
else:
raise ValueError(f"Report {report_id} does not contain a valid configJson")
info = ReportInfo.model_validate(response)
return cls(info=info, environment=environment, config=config, adapter=adapter)
[docs]
@classmethod
def create( # pylint: disable=too-many-arguments,too-many-locals
cls,
*,
name: str,
resources: Iterable[ReportResourceInput],
sections: Iterable[ReportSectionSpec] | None = None,
reference: CaseResourceSelector | None = None,
aliases: Mapping[str, str] | None = None,
description: str = "",
tags: Iterable[str] | None = None,
parent_folder_id: str = DEFAULT_ROOT_FOLDER_ID,
) -> "Report":
"""Create an interactive report and return its cloud resource.
AI-generated workflows should load ``get_agent_guide()`` before constructing
sections. For visualizations, discover every resource's capabilities and run
``check_visualization_setup(...)`` before calling this method.
"""
adapter = cls._adapter
environment = adapter.current_environment()
config = build_report_config(
resources=resources,
sections=sections,
reference=reference,
aliases=aliases,
simulation_json_getter=lambda resource: adapter.get_simulation_json(
resource,
environment=environment,
),
)
return cls._create_from_config(
name=name,
config=config,
description=description,
tags=tags,
parent_folder_id=parent_folder_id,
environment=environment,
adapter=adapter,
)
[docs]
def apply_to_new( # pylint: disable=too-many-arguments
self,
*,
name: str,
resources: Iterable[ReportResourceInput],
reference: CaseResourceSelector | None = None,
aliases: Mapping[str, str] | None = None,
description: str = "",
tags: Iterable[str] | None = None,
parent_folder_id: str | None = None,
) -> "Report":
"""Create a new Report by applying this Report to new cloud resources."""
config = build_apply_to_new_report_config(
source_config=self._config,
resources=resources,
reference=reference,
aliases=aliases,
)
target_folder_id = (
parent_folder_id
if parent_folder_id is not None
else self.parent_folder_id or DEFAULT_ROOT_FOLDER_ID
)
return type(self)._create_from_config(
name=name,
config=config,
description=description,
tags=tags,
parent_folder_id=target_folder_id,
environment=self._environment,
adapter=self._flow360_adapter,
)
[docs]
def refresh(self) -> "Report":
"""Reload this Report's metadata and persisted configuration."""
response = self._flow360_adapter.get_report(self.id, environment=self._environment)
refreshed = type(self)._from_cloud_response(
response=response,
environment=self._environment,
adapter=self._flow360_adapter,
report_id=self.id,
)
self._info = refreshed._info
self._config = refreshed._config
return self
[docs]
def update( # pylint: disable=too-many-arguments
self,
*,
resources: Iterable[ReportResourceInput] | None = None,
sections: Iterable[ReportSectionSpec] | None = None,
reference: CaseResourceSelector | None = None,
aliases: Mapping[str, str] | None = None,
name: str | None = None,
description: str | None = None,
tags: Iterable[str] | None = None,
parent_folder_id: str | None = None,
) -> "Report":
"""Update this Report in place while preserving its cloud ID and URL."""
if (resources is None) != (sections is None):
raise ValueError(
"resources and sections must be provided together when updating config"
)
config_changed = resources is not None
if not config_changed:
if reference is not None or aliases is not None:
raise ValueError("reference and aliases require resources and sections")
config = self._config.model_copy(deep=True)
else:
config = build_report_config(
resources=resources,
sections=sections,
reference=reference,
aliases=aliases,
simulation_json_getter=lambda resource: self._flow360_adapter.get_simulation_json(
resource,
environment=self._environment,
),
)
if name is not None and (not isinstance(name, str) or not name.strip()):
raise ValueError("name must not be empty")
if description is not None and not isinstance(description, str):
raise TypeError("description must be a string")
if parent_folder_id is not None and (
not isinstance(parent_folder_id, str) or not parent_folder_id.strip()
):
raise ValueError("parent_folder_id must not be empty")
request = _UpdateReportRequest(
name=name.strip() if name is not None else None,
description=description.strip() if description is not None else None,
tags=_clean_tags(tags) if tags is not None else None,
parent_folder_id=parent_folder_id.strip() if parent_folder_id is not None else None,
associated_resources=(
[
_ReportResourceRef(id=resource.id, type=resource.type)
for resource in config.resources
]
if config_changed
else None
),
config_json=config.to_json() if config_changed else None,
)
payload = request.to_dict()
if not payload:
return self
response = self._flow360_adapter.update_report(
self.id,
payload,
environment=self._environment,
)
self._info = ReportInfo.model_validate(response)
self._config = config
return self
@property
def id(self) -> str:
"""Cloud report ID."""
return self._info.id
@property
def project_id(self) -> str | None:
"""Project ID created for this report."""
return self._info.project_id
@property
def name(self) -> str:
"""Report name."""
return self._info.name
@property
def description(self) -> str | None:
"""Report description."""
return self._info.description
@property
def tags(self) -> list[str]:
"""Report tags."""
return list(self._info.tags)
@property
def parent_folder_id(self) -> str | None:
"""Parent folder ID."""
return self._info.parent_folder_id
@property
def workspace_id(self) -> str | None:
"""Workspace containing the report."""
return self._info.workspace_id
@property
def status(self) -> str:
"""Current report generation status."""
return self._info.status
@property
def viewed(self) -> bool | None:
"""Whether the report has been viewed."""
return self._info.viewed
@property
def is_deleted(self) -> bool | None:
"""Whether the report has been deleted."""
return self._info.is_deleted
@property
def associated_resources(self) -> list[ReportAssociatedResource]:
"""Resources associated with this report."""
return list(self._info.associated_resources)
@property
def config(self) -> ReportConfig:
"""A defensive copy of the configuration persisted for this Report."""
return self._config.model_copy(deep=True)
@property
def config_json(self) -> str:
"""The persisted Report configuration serialized as JSON."""
return self._config.to_json()
@property
def created_at(self) -> datetime | None:
"""Report creation time."""
return self._info.created_at
@property
def updated_at(self) -> datetime | None:
"""Report last update time."""
return self._info.updated_at
@property
def web_url(self) -> str:
"""Browser URL for this report in the environment where it was created."""
return self._flow360_adapter.web_url(self.id, environment=self._environment)
def __repr__(self) -> str:
return f"Report(id={self.id!r}, name={self.name!r}, status={self.status!r})"