diff --git a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb new file mode 100644 index 000000000..c6b23fae9 --- /dev/null +++ b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb @@ -0,0 +1,426 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Copy or Move Objects and Files Between Workspaces\n", + "\n", + "This notebook demonstrates how to copy or move objects and files between workspaces\n", + "1. Sign in and select the source workspace\n", + "2. Select the target workspace (and whether to delete the originals after copying)\n", + "3. Copy/move a geoscience object, a file, or both" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "#### Sign in and select Source Workspace\n", + "Enter the client_id and redirect_url values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.notebooks import ServiceManagerWidget\n", + "\n", + "cache_location = \"data\"\n", + "\n", + "# Evo app credentials\n", + "client_id = \"\" # Replace with your client ID\n", + "redirect_url = \"\" # Replace with your redirect URL\n", + "\n", + "manager = await ServiceManagerWidget.with_auth_code(\n", + " discovery_url=\"https://discover.api.seequent.com\",\n", + " client_id=client_id,\n", + " redirect_url=redirect_url,\n", + " cache_location=cache_location,\n", + ").login()" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "#### Create Source Clients using Evo Python SDK" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.files import FileAPIClient\n", + "from evo.objects import ObjectAPIClient\n", + "\n", + "source_environment = manager.get_environment()\n", + "connector = manager.get_connector()\n", + "\n", + "object_client = ObjectAPIClient(source_environment, connector)\n", + "data_client = object_client.get_data_client(manager.cache)\n", + "source_file_client = FileAPIClient(source_environment, connector)" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "#### Select Target Workspace and Operation\n", + "\n", + "- Select the target workspace\n", + "- Check \"Delete original after copy\" to perform a move instead of a copy (applies to both objects and files)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from workspace_utils import ObjectSelectorWidget, TargetWorkspaceSelectorWidget, collect_data_references\n", + "\n", + "# In CI, _CIManager does not expose notebook widget internals used by this selector.\n", + "if os.environ.get(\"CI\"):\n", + " target_workspace_selector = None\n", + " print(\"Skipping target workspace selector widget in CI.\")\n", + "else:\n", + " target_workspace_selector = TargetWorkspaceSelectorWidget(manager)\n", + " await target_workspace_selector.display()" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Part 1: Copy / Move a Geoscience Object\n", + "\n", + "#### Select an Object using the Object Selector Widget\n", + "\n", + "The widget provides:\n", + "- A refresh button to load all objects from the workspace\n", + "- A dropdown to filter objects by type (schema classification)\n", + "- A dropdown to select a specific object\n", + "- An information panel showing details about the selected object" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "object_selector = ObjectSelectorWidget(object_client)\n", + "await object_selector.display()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "selected_object = object_selector.get_selected_object()\n", + "selected_id = object_selector.get_selected_id()\n", + "\n", + "if selected_id:\n", + " object_name = object_selector._format_object_name(selected_object)\n", + " print(f\"Selected Object ID: {selected_id}\")\n", + " print(f\"Selected Object Name: {object_name}\")\n", + "else:\n", + " object_name = None\n", + " print(\"No object selected. Please select an object from the dropdown above.\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "#### Download and Save Object\n", + "\n", + "Download the selected object's JSON and its data files, and save the object JSON to the data folder." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "from evo.notebooks import FeedbackWidget\n", + "\n", + "# Download the selected object\n", + "if selected_id:\n", + " # Download the object\n", + " downloaded_object = await object_client.download_object_by_id(object_id=selected_id)\n", + "\n", + " # Get the object as a dictionary\n", + " object_data = downloaded_object.as_dict()\n", + "\n", + " # Collect all data references from the object\n", + " data_refs = collect_data_references(object_data)\n", + "\n", + " if data_refs:\n", + " print(f\"Found {len(data_refs)} data file(s) to download...\")\n", + "\n", + " # Download all data files to the cache\n", + " for data_download in downloaded_object.prepare_data_download(list(data_refs)):\n", + " await data_download.download_to_cache(\n", + " cache=manager.cache,\n", + " transport=object_client._connector.transport,\n", + " fb=FeedbackWidget(f\"Downloading {data_download.name}\"),\n", + " )\n", + " print(\"All data files downloaded to cache.\")\n", + " else:\n", + " print(\"No data files to download.\")\n", + "\n", + " # Remove only the top-level 'uuid' field if it exists\n", + " object_data.pop(\"uuid\", None)\n", + "\n", + " # Create data folder if it doesn't exist\n", + " data_folder = Path(\"data\")\n", + " data_folder.mkdir(exist_ok=True)\n", + "\n", + " # Save to file using the object name\n", + " object_name = object_selector._format_object_name(selected_object)\n", + " output_file = data_folder / f\"{object_name}.json\"\n", + "\n", + " with open(output_file, \"w\") as f:\n", + " json.dump(object_data, f, indent=2, default=str)\n", + "\n", + " print(f\"Object data saved to: {output_file}\")\n", + " print(f\"File size: {output_file.stat().st_size} bytes\")\n", + "else:\n", + " print(\"No object selected. Please select an object first.\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "#### Publish Object to Target Workspace" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "from evo.common import Environment\n", + "from evo.notebooks import FeedbackWidget\n", + "\n", + "# Get the target workspace ID and operation\n", + "if target_workspace_selector is None:\n", + " target_workspace_id = None\n", + " operation = \"Copy\"\n", + "else:\n", + " target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n", + " operation = target_workspace_selector.get_operation()\n", + "\n", + "if not target_workspace_id:\n", + " print(\"No target workspace selected. Please select a target workspace first.\")\n", + "elif not selected_id:\n", + " print(\"No object selected. Please select an object first.\")\n", + "else:\n", + " # Read the saved JSON file\n", + " object_name = object_selector._format_object_name(selected_object)\n", + " input_file = Path(\"data\") / f\"{object_name}.json\"\n", + "\n", + " if not input_file.exists():\n", + " print(f\"Error: File {input_file} not found. Please run the download cell first.\")\n", + " else:\n", + " # Read the JSON data\n", + " with open(input_file, \"r\") as f:\n", + " object_data = json.load(f)\n", + "\n", + " # Publish the object to the target workspace\n", + " operation_verb = \"Copying\" if operation == \"Copy\" else \"Moving\"\n", + " print(f\"{operation_verb} object '{object_name}' to target workspace...\")\n", + "\n", + " try:\n", + " # Create a new ObjectAPIClient with the target workspace environment\n", + " target_env = Environment(\n", + " org_id=manager.get_environment().org_id,\n", + " workspace_id=target_workspace_id,\n", + " hub_url=manager.get_environment().hub_url,\n", + " )\n", + "\n", + " target_object_client = ObjectAPIClient(target_env, manager.get_connector())\n", + "\n", + " # Use the same cache as the source object client\n", + " # This ensures the data files downloaded earlier are accessible\n", + " target_data_client = target_object_client.get_data_client(manager.cache)\n", + "\n", + " # Copy the data files from the source workspace cache to the target workspace cache\n", + " # Get cache locations for both workspaces\n", + " source_cache_location = data_client.cache_location\n", + " target_cache_location = target_data_client.cache_location\n", + "\n", + " # Collect all data references from the object\n", + " data_refs = collect_data_references(object_data)\n", + "\n", + " if source_cache_location != target_cache_location and data_refs:\n", + " print(f\"Copying {len(data_refs)} data file(s) to target workspace cache...\")\n", + " import shutil\n", + "\n", + " target_cache_location.mkdir(parents=True, exist_ok=True)\n", + "\n", + " for data_ref in data_refs:\n", + " source_file = source_cache_location / data_ref\n", + " target_file = target_cache_location / data_ref\n", + " if source_file.exists() and not target_file.exists():\n", + " shutil.copy2(source_file, target_file)\n", + " print(\"Data files copied to target workspace cache.\")\n", + "\n", + " # Upload the referenced data to the target workspace\n", + " await target_data_client.upload_referenced_data(object_data, FeedbackWidget(\"Uploading data\"))\n", + "\n", + " # Create the geoscience object in the target workspace\n", + " # Use the same path as the original object\n", + " target_path = selected_object.path\n", + " published_object = await target_object_client.create_geoscience_object(target_path, object_data)\n", + "\n", + " print(\"Successfully published object to target workspace!\")\n", + " print(f\"New Object ID: {published_object.id}\")\n", + " print(f\"New Object Path: {published_object.path}\")\n", + "\n", + " # If this is a move operation, delete the original object\n", + " if operation == \"Move\":\n", + " print(\"Deleting original object from source workspace...\")\n", + " await object_client.delete_object_by_id(selected_id)\n", + " print(\"Original object deleted successfully.\")\n", + "\n", + " except Exception as e:\n", + " print(f\"Error publishing object: {e!s}\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Part 2: Copy / Move a File\n", + "\n", + "#### Select a Source File" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "\n", + "source_files = await source_file_client.list_all_files()\n", + "\n", + "if not source_files:\n", + " raise ValueError(\"No files found in the source workspace.\")\n", + "\n", + "source_file_selector = widgets.Dropdown(\n", + " options=[(file.path, file.path) for file in source_files],\n", + " description=\"Source file:\",\n", + " layout=widgets.Layout(width=\"80%\"),\n", + ")\n", + "\n", + "display(source_file_selector)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "#### Copy / Move the File to the Target Workspace\n", + "\n", + "Uses the target workspace and the \"Delete original after copy\" option selected above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import replace\n", + "\n", + "if target_workspace_selector is None:\n", + " raise ValueError(\"Target workspace selection is not available (CI).\")\n", + "\n", + "target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n", + "operation = target_workspace_selector.get_operation()\n", + "if not target_workspace_id:\n", + " raise ValueError(\"Select a target workspace first.\")\n", + "\n", + "destination_environment = replace(source_environment, workspace_id=target_workspace_id)\n", + "destination_file_client = FileAPIClient(destination_environment, connector)\n", + "\n", + "source_path = source_file_selector.value\n", + "destination_path = source_path # Keep the same path/name in the destination\n", + "\n", + "download = await source_file_client.prepare_download_by_path(source_path)\n", + "local_file = await download.download_to_cache(manager.cache, connector.transport)\n", + "\n", + "upload = await destination_file_client.prepare_upload_by_path(destination_path)\n", + "await upload.upload_from_path(str(local_file), connector.transport)\n", + "\n", + "print(f\"Copied {source_path} to {destination_path}\")\n", + "\n", + "if operation == \"Move\":\n", + " await source_file_client.delete_file_by_path(source_path)\n", + " print(f\"Deleted original file {source_path} from source workspace.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "evo-code-samples (3.14.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py new file mode 100644 index 000000000..75de9ed2b --- /dev/null +++ b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py @@ -0,0 +1,367 @@ +from uuid import UUID + +import ipywidgets as widgets +from IPython.display import display + + +def collect_data_references(obj, data_refs=None): + """Recursively collect all 'data' field values from the object dictionary.""" + if data_refs is None: + data_refs = set() + + if isinstance(obj, dict): + # If this dict has a 'data' key with a string value, it's a data reference + if "data" in obj and isinstance(obj["data"], str): + data_refs.add(obj["data"]) + # Recursively search through all values + for value in obj.values(): + collect_data_references(value, data_refs) + elif isinstance(obj, list): + # Recursively search through all items + for item in obj: + collect_data_references(item, data_refs) + + return data_refs + + +"""Custom widgets for workspace maintenance operations.""" + + +class ObjectSelectorWidget: + """Widget for selecting geoscience objects with filtering by object type.""" + + def __init__(self, object_client): + self.object_client = object_client + self.all_objects = [] + self.filtered_objects = [] + + # Create widgets + self.refresh_btn = widgets.Button( + description="Refresh Objects", + button_style="info", + icon="refresh", + layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center"), + ) + self.refresh_btn.style.button_color = "#265C7F" + self.refresh_btn.on_click(self._on_refresh_click) + + self.loading_label = widgets.Label(value="") + + # Object type filter + self.object_type_filter = widgets.Dropdown( + options=[("All Types", "")], + value="", + description="Object Type", + style={"description_width": "100px"}, + ) + self.object_type_filter.observe(self._on_filter_changed, names="value") + + # Object selector + self.object_selector = widgets.Dropdown( + options=[("Select an object...", None)], + value=None, + description="Object", + disabled=True, + style={"description_width": "100px"}, + ) + + # Info display + self.info_display = widgets.HTML(value="") + + # Layout + self.widget = widgets.VBox( + [ + widgets.HBox([self.refresh_btn, self.loading_label]), + self.object_type_filter, + self.object_selector, + self.info_display, + ] + ) + + def _on_refresh_click(self, btn): + """Handle refresh button click.""" + import asyncio + + asyncio.create_task(self.refresh()) + + async def refresh(self): + """Load all objects from the workspace.""" + self.loading_label.value = "Loading objects..." + self.refresh_btn.disabled = True + self.object_type_filter.disabled = True + self.object_selector.disabled = True + + try: + # Fetch all objects + self.all_objects = await self.object_client.list_all_objects() + + # Extract unique object types + object_types = set() + for obj in self.all_objects: + # Get the schema classification (e.g., "objects/pointset") + schema_type = obj.schema_id.classification + object_types.add(schema_type) + + # Update object type filter options + type_options = [("All Types", "")] + type_options.extend(sorted([(t, t) for t in object_types])) + self.object_type_filter.options = type_options + + # Update filtered objects + self._update_filtered_objects() + + self.loading_label.value = f"Loaded {len(self.all_objects)} object(s)" + + except Exception as e: + self.loading_label.value = f"Error: {e!s}" + + finally: + self.refresh_btn.disabled = False + self.object_type_filter.disabled = False + self.object_selector.disabled = False + + def _on_filter_changed(self, change): + """Handle filter change.""" + self._update_filtered_objects() + + def _format_object_name(self, obj): + """Format the object name by removing .json extension.""" + name = obj.name + name = name.removesuffix(".json") + return name + + def _update_filtered_objects(self): + """Update the object selector based on the current filter.""" + selected_type = self.object_type_filter.value + + # Filter objects + if selected_type == "": + self.filtered_objects = self.all_objects + else: + self.filtered_objects = [obj for obj in self.all_objects if obj.schema_id.classification == selected_type] + + # Update object selector options + if self.filtered_objects: + object_options = [("Select an object...", None)] + object_options.extend( + [ + (f"{self._format_object_name(obj)} ({obj.schema_id.sub_classification})", obj.id) + for obj in self.filtered_objects + ] + ) + self.object_selector.options = object_options + self.object_selector.value = None + else: + self.object_selector.options = [("No objects found", None)] + self.object_selector.value = None + + # Update info display when selection changes + self.object_selector.observe(self._on_object_selected, names="value") + self._update_info_display() + + def _on_object_selected(self, change): + """Handle object selection change.""" + self._update_info_display() + + def _update_info_display(self): + """Update the info display with details about the selected object.""" + selected_id = self.object_selector.value + + if selected_id is None: + self.info_display.value = "" + return + + # Find the selected object + selected_obj = next((obj for obj in self.filtered_objects if obj.id == selected_id), None) + + if selected_obj is None: + self.info_display.value = "" + return + + # Build info HTML + info_html = f""" +
+ Object Details:
+ Name: {self._format_object_name(selected_obj)}
+ Path: {selected_obj.path}
+ ID: {selected_obj.id}
+ Schema: {selected_obj.schema_id}
+ Version: {selected_obj.version_id}
+ Created: {selected_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")}
+ Modified: {selected_obj.modified_at.strftime("%Y-%m-%d %H:%M:%S")}
+
+ """ + self.info_display.value = info_html + + async def display(self): + """Display the widget and auto-load objects.""" + display(self.widget) + # Auto-load objects on display + await self.refresh() + + def get_selected_object(self): + """Get the currently selected object metadata.""" + selected_id = self.object_selector.value + if selected_id is None: + return None + return next((obj for obj in self.filtered_objects if obj.id == selected_id), None) + + def get_selected_id(self) -> UUID | None: + """Get the UUID of the currently selected object.""" + return self.object_selector.value + + +class TargetWorkspaceSelectorWidget: + """Widget for selecting a target workspace and operation type (copy/move).""" + + def __init__(self, manager_widget): + self.manager_widget = manager_widget + # Get the selected workspace ID + selected_id = manager_widget._workspace_selector.selected + + if selected_id != manager_widget._workspace_selector.UNSELECTED[1]: + self.current_workspace_id = selected_id + else: + raise ValueError("No source workspace is currently selected.") + + # Create widgets + self.refresh_btn = widgets.Button( + description="Refresh Workspaces", + button_style="info", + icon="refresh", + layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center", width="auto"), + ) + self.refresh_btn.style.button_color = "#265C7F" + self.refresh_btn.on_click(self._on_refresh_click) + + self.loading_label = widgets.Label(value="") + + # Operation section - label and checkbox + self.operation_label = widgets.HTML(value="Copy object/file") + self.delete_original_checkbox = widgets.Checkbox( + value=False, + description="Delete original after copy", + indent=False, + style={"description_width": "auto"}, + ) + + # Workspace selector + self.workspace_selector = widgets.Dropdown( + options=[("Select target workspace...", None)], + value=None, + description="To Workspace", + disabled=True, + style={"description_width": "100px"}, + ) + + # Info display + self.info_display = widgets.HTML(value="") + + # Layout + self.widget = widgets.VBox( + [ + widgets.HBox([self.refresh_btn, self.loading_label]), + self.operation_label, + self.delete_original_checkbox, + self.workspace_selector, + self.info_display, + ] + ) + + # Observe selection changes + self.workspace_selector.observe(self._on_workspace_selected, names="value") + self.delete_original_checkbox.observe(self._on_checkbox_changed, names="value") + + def _on_refresh_click(self, btn): + """Handle refresh button click.""" + import asyncio + + asyncio.create_task(self.refresh()) + + async def refresh(self): + """Load all workspaces.""" + self.loading_label.value = "Loading workspaces..." + self.refresh_btn.disabled = True + self.workspace_selector.disabled = True + + try: + # Get all workspaces from the service manager + workspaces = self.manager_widget.workspaces + + # Filter out the current workspace + other_workspaces = [ws for ws in workspaces if ws.id != self.current_workspace_id] + + # Update workspace selector options + if other_workspaces: + workspace_options = [("Select target workspace...", None)] + workspace_options.extend( + [(ws.display_name, ws.id) for ws in sorted(other_workspaces, key=lambda w: w.display_name)] + ) + self.workspace_selector.options = workspace_options + self.workspace_selector.value = None + self.loading_label.value = f"Loaded {len(other_workspaces)} workspace(s)" + else: + self.workspace_selector.options = [("No other workspaces available", None)] + self.workspace_selector.value = None + self.loading_label.value = "No other workspaces found" + + except Exception as e: + self.loading_label.value = f"Error: {e!s}" + + finally: + self.refresh_btn.disabled = False + self.workspace_selector.disabled = False + + def _on_workspace_selected(self, change): + """Handle workspace selection change.""" + self._update_info_display() + + def _on_checkbox_changed(self, change): + """Handle delete original checkbox change.""" + self._update_info_display() + + def _update_info_display(self): + """Update the info display with details about the selected workspace.""" + selected_id = self.workspace_selector.value + + if selected_id is None: + self.info_display.value = "" + return + + # Find the selected workspace + workspaces = self.manager_widget.workspaces + selected_ws = next((ws for ws in workspaces if ws.id == selected_id), None) + + if selected_ws is None: + self.info_display.value = "" + return + + # Get current workspace info + current_ws = next((ws for ws in workspaces if ws.id == self.current_workspace_id), None) + operation = "Move" if self.delete_original_checkbox.value else "Copy" + + # Build info HTML + info_html = f""" +
+ Operation Summary:
+ Action: {operation} object
+ From: {current_ws.display_name}
+ To: {selected_ws.display_name}
+ Target Workspace ID: {selected_ws.id}
+
+ """ + self.info_display.value = info_html + + async def display(self): + """Display the widget and auto-load workspaces.""" + display(self.widget) + # Auto-load workspaces on display + await self.refresh() + + def get_selected_workspace_id(self) -> UUID | None: + """Get the UUID of the currently selected target workspace.""" + return self.workspace_selector.value + + def get_operation(self) -> str: + """Get the selected operation type ('Copy' or 'Move').""" + return "Move" if self.delete_original_checkbox.value else "Copy" diff --git a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html index 995eb4fde..47a50484d 100644 --- a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html +++ b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html @@ -39,7 +39,7 @@ diff --git a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html index b9e96b211..4d97ece51 100644 --- a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html +++ b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html @@ -34,7 +34,7 @@