from datetime import datetime
from typing import Union, Dict, Any, List, Optional, Set
from odmantic import Model
[docs]
def make_table_entries_helper(
model_instance,
table_name=None,
max_recursion_level=1,
drop_id=True,
current_level=0,
visited=None,
field_prefix="",
):
"""
Create table data for AG Grid from a model instance.
Handles datetime objects properly and can optionally drop ID fields.
Args:
model_instance: The model instance to process
table_name: Optional name for the table (default: None, uses class name)
max_recursion_level: Maximum depth for processing nested models (default: 1)
drop_id: Whether to drop the ID field (default: True)
current_level: Current recursion level (default: 0)
visited: Set of objects already visited to prevent infinite recursion (default: None)
field_prefix: Prefix for field paths in nested structures (default: "")
Returns:
dict: Dictionary with 'tableName' and 'tableData' (rows data for AG Grid)
"""
# Initialize the visited set to track objects for preventing infinite recursion
if visited is None:
visited = set()
# If the instance has already been visited, stop recursion
if id(model_instance) in visited:
return {"tableData": {}}
# Mark this instance as visited
visited.add(id(model_instance))
# Get model class and fields
model_class = type(model_instance)
if hasattr(model_instance, "make_table_entries"):
field_prefix = f"{field_prefix}." if field_prefix else ""
field_prefix = (
f"{field_prefix}{model_instance.__class__.__name__}"
if field_prefix
else model_instance.__class__.__name__
)
return model_instance.make_table_entries(
max_recursion_level=1,
drop_id=True,
current_level=0,
visited=None,
field_prefix="",
)
model_fields = getattr(model_instance, "model_fields", {})
# Use class name if table_name not provided
if table_name is None:
table_name = model_class.__name__
# Initialize empty summary dictionary
summary = {}
# Process each field
for field_name, field_type in model_fields.items():
# Skip ID fields if drop_id is True
if drop_id and (field_name == "id" or field_name == "_id"):
continue
# Calculate the full field path for AG Grid
field_path = f"{field_prefix}.{field_name}" if field_prefix else field_name
# Get field value
field_value = getattr(model_instance, field_name, None)
# If the field value is None, add it to summary
if field_value is None:
summary[field_name] = None
continue
# Handle datetime objects for AG Grid
if isinstance(field_value, datetime):
summary[field_name] = field_value.isoformat()
continue
# Check if the field is a nested model
is_nested_model = hasattr(field_value, "model_fields")
# If the recursion level is 0, exclude all nested objects
if max_recursion_level == 0 and is_nested_model:
continue
# Handle nested model if it exists and we haven't reached max recursion
if is_nested_model and current_level < max_recursion_level:
# Check if the object has its own make_table method
if hasattr(field_value, "make_table_entries"):
nested_result = field_value.make_table_entries(
max_recursion_level=max_recursion_level,
current_level=current_level + 1,
visited=visited,
field_prefix=f"{field_path}.",
)
if isinstance(nested_result, dict) and "tableData" in nested_result:
nested_summary = nested_result["tableData"]
else:
# If make_table doesn't return expected format
nested_summary = nested_result
else:
# Process the nested model recursively
nested_result = make_table_entries_helper(
field_value,
f"{table_name}_{field_name}",
max_recursion_level,
drop_id,
current_level + 1,
visited,
f"{field_path}.",
)
nested_summary = nested_result
# Add the nested object as a sub-dictionary
summary[field_name] = nested_summary
elif field_type == "self" and current_level < max_recursion_level:
# Handle self-referential field, but only go one level deep to avoid cycles
raise ValueError(
f"Self-referential field '{field_name}' is not supported in make_table_entries."
)
if field_value is not None and id(field_value) not in visited:
# Process only the direct fields of the self-reference
nested_result = make_table_entries_helper(
field_value,
f"{table_name}_{field_name}",
0, # Only process direct fields, no further recursion
drop_id,
current_level + 1,
visited,
f"{field_path}.",
)
nested_summary = nested_result["tableData"]
summary[field_name] = nested_summary
else:
# Handle lists/arrays
if isinstance(field_value, list):
processed_list = []
for item in field_value:
if isinstance(item, datetime):
processed_list.append(item.isoformat())
elif (
hasattr(item, "model_fields")
and current_level < max_recursion_level
):
# Handle nested models in lists
if hasattr(item, "make_table"):
nested_result = item.make_table_entries(
max_recursion_level=0,
current_level=current_level + 1,
visited=visited.copy(),
)
if (
isinstance(nested_result, dict)
and "tableData" in nested_result
):
processed_list.append(nested_result["tableData"])
else:
processed_list.append(nested_result)
else:
nested_result = make_table_entries_helper(
item,
f"{table_name}_{field_name}_item",
0,
drop_id,
current_level + 1,
visited.copy(),
"",
)
if (
isinstance(nested_result, dict)
and "tableData" in nested_result
):
processed_list.append(nested_result["tableData"])
else:
processed_list.append(nested_result)
else:
processed_list.append(item)
summary[field_name] = processed_list
else:
# This is a simple field, add it directly
summary[field_name] = field_value
return summary
[docs]
def is_pydantic_model(obj):
"""
Check if an object is a Pydantic model class.
Args:
obj: The object to check
Returns:
bool: True if the object is a Pydantic model class, False otherwise
"""
# For Pydantic V1
if hasattr(obj, "__fields__"):
return True
# For Pydantic V2
if hasattr(obj, "model_fields"):
return True
# Check for BaseModel inheritance (works for both V1 and V2)
if isinstance(obj, type):
from inspect import getmro
for base in getmro(obj):
if base.__name__ == "BaseModel":
return True
# Handle typing objects like Optional[Model]
if hasattr(obj, "__origin__") and hasattr(obj, "__args__"):
# Check if it's a Union type (which includes Optional)
from typing import _SpecialGenericAlias, _GenericAlias
if (
isinstance(obj, (_SpecialGenericAlias, _GenericAlias))
and obj.__origin__ is Union
):
return any(is_pydantic_model(arg) for arg in obj.__args__)
return False
[docs]
def make_column_defs_helper(
model_class,
table_name=None,
max_recursion_level=1,
drop_id=True,
current_level=0,
visited=None,
field_prefix="",
):
"""
Create column definitions for AG Grid based on a model class.
Uses the same logic as table data generation but for model class.
This is a helper function mapped to make_column_defs in simstack_model.
The idea is that most classes can call the helper function but some classes might
want to override it to return specific column definitions.
Args:
model_class: The model class to process
table_name: Optional name for the table (default: None, uses class name)
max_recursion_level: Maximum depth for processing nested models (default: 1)
drop_id: Whether to drop the ID field (default: True)
current_level: Current recursion level (default: 0)
visited: Set of objects already visited to prevent infinite recursion (default: None)
field_prefix: Prefix for field paths in nested structures (default: "")
Returns:
list: Column definitions for AG Grid
"""
from typing import get_origin, get_args, List, Union
# Initialize a visited set to track classes for preventing infinite recursion
if visited is None:
visited = set()
# If the class has already been visited, stop recursion
if id(model_class) in visited:
return []
# Mark this class as visited
visited.add(id(model_class))
if hasattr(model_class, "make_column_defs"):
return model_class.make_column_defs(
model_class,
table_name=None,
max_recursion_level=1,
drop_id=True,
current_level=0,
visited=None,
field_prefix="",
)
# Get model fields
model_fields = getattr(
model_class, "model_fields", getattr(model_class, "__fields__", {})
)
# Use class name if table_name not provided
if table_name is None:
table_name = model_class.__name__
# Initialize column definitions
column_defs = []
# Process each field
for field_name, field_info in model_fields.items():
# Skip ID fields if drop_id is True
if drop_id and (field_name == "id" or field_name == "_id"):
continue
# Calculate the full field path for AG Grid
field_path = f"{field_prefix}{field_name}" if field_prefix else field_name
# Extract field type (different in V1 and V2)
if hasattr(field_info, "annotation"):
# Pydantic V2
field_type = field_info.annotation
elif hasattr(field_info, "type_"):
# Pydantic V1
field_type = field_info.type_
else:
# Fallback
field_type = None
# Check if the field type is a Pydantic model
is_nested_model = is_pydantic_model(field_type)
# Extract actual model class for nested types (like Optional[Model])
if is_nested_model and hasattr(field_type, "__args__"):
args = get_args(field_type)
for arg in args:
if is_pydantic_model(arg):
field_type = arg
break
# Create column definition based on the field type
column_def = create_column_def(field_name, field_type, None, field_path)
# Set object type for nested models
if is_nested_model:
column_def["type"] = "objectColumn"
# If recursion level is 0, exclude all nested objects but include column def for the field itself
if max_recursion_level == 0 and is_nested_model:
column_defs.append(column_def)
continue
# Handle nested model class if it exists and we haven't reached max recursion
if is_nested_model and current_level < max_recursion_level:
# Check if the class has its own make_columns method
if hasattr(field_type, "make_column_defs"):
nested_columns = field_type.make_column_defs(
max_recursion_level=max_recursion_level,
current_level=current_level + 1,
visited=visited.copy(),
)
else:
# Process the nested model class recursively
nested_columns = make_column_defs_helper(
field_type,
f"{table_name}_{field_name}",
max_recursion_level,
drop_id,
current_level + 1,
visited.copy(),
f"{field_path}.",
)
# Create a column group for nested fields if we have columns
if nested_columns:
group_column = {
"headerName": field_name.capitalize(),
"marryChildren": True,
"children": nested_columns,
}
column_defs.append(group_column)
else:
column_defs.append(column_def)
elif field_type == "self" and current_level < max_recursion_level:
# Handle self-referential field type
# Create a new visited set for this branch to avoid affecting other branches
self_visited = visited.copy()
if model_class not in self_visited:
# Process only the direct fields of the self-reference
nested_columns = make_column_defs_helper(
model_class,
f"{table_name}_{field_name}",
0, # Only process direct fields, no further recursion
drop_id,
current_level + 1,
self_visited,
f"{field_path}.",
)
# Create a column group for self-referential fields
if nested_columns:
group_column = {
"headerName": field_name.capitalize(),
"marryChildren": True,
"children": nested_columns,
}
column_defs.append(group_column)
else:
column_defs.append(column_def)
else:
column_defs.append(column_def)
else:
# Handle lists/arrays
is_list_type = False
inner_type = None
# Check for various list types using get_origin and get_args
origin = get_origin(field_type)
if origin is list or origin is List:
is_list_type = True
args = get_args(field_type)
if args:
inner_type = args[0]
# Handle Optional[List[...]]
elif origin is Union:
args = get_args(field_type)
for arg in args:
arg_origin = get_origin(arg)
if arg_origin is list or arg_origin is List:
is_list_type = True
arg_args = get_args(arg)
if arg_args:
inner_type = arg_args[0]
break
if is_list_type:
# For lists, we add a special column definition
list_column_def = {
"headerName": field_name.capitalize(),
"field": field_path,
"cellRenderer": "agArrayCellRenderer",
}
# If the list contains models, we might want to indicate that
if inner_type and is_pydantic_model(inner_type):
list_column_def["type"] = "modelArrayColumn"
column_defs.append(list_column_def)
else:
# This is a simple field, add column definition directly
column_defs.append(column_def)
return column_defs
[docs]
def make_column_defs_instance(
model_instance: Model,
table_name: Optional[str] = None,
max_recursion_level: int = 1,
drop_id: bool = True,
current_level: int = 0,
visited: Optional[Set[int]] = None,
field_prefix: str = "",
) -> List[Dict[str, Any]]:
"""
Create column definitions for AG Grid based on a model instance.
Uses the same logic as table data generation but works on an actual instance.
This allows for dynamic column generation based on actual data values.
Args:
model_instance: The model instance to process
table_name: Optional name for the table (default: None, uses class name)
max_recursion_level: Maximum depth for processing nested models (default: 1)
drop_id: Whether to drop the ID field (default: True)
current_level: Current recursion level (default: 0)
visited: Set of objects already visited to prevent infinite recursion (default: None)
field_prefix: Prefix for field paths in nested structures (default: "")
Returns:
list: Column definitions for AG Grid
"""
from typing import get_origin, get_args, List, Union
# Initialize a visited set to track instances for preventing infinite recursion
if visited is None:
visited = set()
# If the instance has already been visited, stop recursion
instance_id = id(model_instance)
if instance_id in visited:
return []
# Mark this instance as visited
visited.add(instance_id)
# Check if the instance has a custom make_column_defs method
if hasattr(model_instance, "make_column_defs_instance") and callable(
getattr(model_instance, "make_column_defs_instance")
):
return model_instance.make_column_defs_instance(
table_name=table_name,
max_recursion_level=max_recursion_level,
drop_id=drop_id,
current_level=current_level,
visited=visited.copy(),
field_prefix=field_prefix,
)
# Get model class and fields
model_class = type(model_instance)
model_fields = getattr(
model_class, "model_fields", getattr(model_class, "__fields__", {})
)
# Use class name if table_name not provided
if table_name is None:
table_name = model_class.__name__
# Initialize column definitions
column_defs = []
# Process each field
for field_name, field_info in model_fields.items():
# Skip ID fields if drop_id is True
if drop_id and (field_name == "id" or field_name == "_id"):
continue
# Calculate the full field path for AG Grid
field_path = f"{field_prefix}.{field_name}" if field_prefix else field_name
# Get the actual field value from the instance
try:
field_value = getattr(model_instance, field_name, None)
except AttributeError:
field_value = None
# Extract field type (different in V1 and V2)
if hasattr(field_info, "annotation"):
# Pydantic V2
field_type = field_info.annotation
elif hasattr(field_info, "type_"):
# Pydantic V1
field_type = field_info.type_
else:
# Fallback - infer from actual value
field_type = type(field_value) if field_value is not None else str
# Check if the field type is a Pydantic model
is_nested_model = is_pydantic_model(field_type)
# Extract actual model class for nested types (like Optional[Model])
if is_nested_model and hasattr(field_type, "__args__"):
args = get_args(field_type)
for arg in args:
if is_pydantic_model(arg):
field_type = arg
break
# For instances, we can also infer type from actual value
if field_value is not None and is_pydantic_model(type(field_value)):
field_type = type(field_value)
is_nested_model = True
# Create column definition based on the field type and actual value
column_def = create_column_def(field_name, field_type, field_value, field_path)
# Set object type for nested models
if is_nested_model:
column_def["type"] = "objectColumn"
# If recursion level is 0, exclude all nested objects but include column def for the field itself
if max_recursion_level == 0 and is_nested_model:
column_defs.append(column_def)
continue
# Handle nested model instance if it exists and we haven't reached max recursion
if (
is_nested_model
and field_value is not None
and current_level < max_recursion_level
):
# Check if the instance has its own make_column_defs method
if hasattr(field_value, "make_column_defs_instance") and callable(
getattr(field_value, "make_column_defs_instance")
):
nested_columns = field_value.make_column_defs_instance(
max_recursion_level=max_recursion_level,
current_level=current_level + 1,
visited=visited.copy(),
field_prefix=f"{field_path}." if field_path else "",
)
else:
# Process the nested model instance recursively
nested_columns = make_column_defs_instance(
field_value,
f"{table_name}_{field_name}",
max_recursion_level,
drop_id,
current_level + 1,
visited.copy(),
f"{field_path}.",
)
# Create a column group for nested fields if we have columns
if nested_columns:
group_column = {
"headerName": field_name.capitalize(),
"marryChildren": True,
"children": nested_columns,
}
column_defs.append(group_column)
else:
column_defs.append(column_def)
elif field_type == "self" and current_level < max_recursion_level:
# Handle self-referential field type
if field_value is not None:
# Create a new visited set for this branch to avoid affecting other branches
self_visited = visited.copy()
if instance_id not in self_visited:
# Process only the direct fields of the self-reference
nested_columns = make_column_defs_instance(
field_value,
f"{table_name}_{field_name}",
0, # Only process direct fields, no further recursion
drop_id,
current_level + 1,
self_visited,
f"{field_path}.",
)
# Create a column group for self-referential fields
if nested_columns:
group_column = {
"headerName": field_name.capitalize(),
"marryChildren": True,
"children": nested_columns,
}
column_defs.append(group_column)
else:
column_defs.append(column_def)
else:
column_defs.append(column_def)
else:
column_defs.append(column_def)
else:
# Handle lists/arrays
is_list_type = False
inner_type = None
# Check for various list types using get_origin and get_args
origin = get_origin(field_type)
if origin is list or origin is List:
is_list_type = True
args = get_args(field_type)
if args:
inner_type = args[0]
# Handle Optional[List[...]]
elif origin is Union:
args = get_args(field_type)
for arg in args:
arg_origin = get_origin(arg)
if arg_origin is list or arg_origin is List:
is_list_type = True
arg_args = get_args(arg)
if arg_args:
inner_type = arg_args[0]
break
# Also check if the actual field value is a list
if field_value is not None and isinstance(field_value, list):
is_list_type = True
# Try to infer inner type from first non-None element
if field_value and inner_type is None:
for item in field_value:
if item is not None:
inner_type = type(item)
break
if is_list_type:
# For lists, we add a special column definition
list_column_def = {
"headerName": field_name.capitalize(),
"field": field_path,
"cellRenderer": "agArrayCellRenderer",
}
# If the list contains models, we might want to indicate that
if inner_type and is_pydantic_model(inner_type):
list_column_def["type"] = "modelArrayColumn"
# If we have actual list data, we can provide more specific info
if field_value is not None and isinstance(field_value, list):
list_column_def["listLength"] = len(field_value)
if field_value:
# Add info about the first item for reference
first_item = field_value[0]
if is_pydantic_model(type(first_item)):
list_column_def["itemType"] = type(first_item).__name__
column_defs.append(list_column_def)
else:
# This is a simple field, add column definition directly
column_defs.append(column_def)
return column_defs
[docs]
def make_table_entries(
model_instances, table_name=None, max_recursion_level=1, drop_id=True
):
"""
Create AG Grid table configuration for a list of model instances.
This is the main function to make tables that are visible in the ui.
To generate a table in the ui the ui_schema of the class should initialize
the grid_options for the table.
It is basically impossible to predict what a table should be for a class but
tables can be created only from lists. The make_table function of the class should
therefore call this function to generate the table for appropriate list fields and
merge these manually where it makes sense.
This function calls the helper functions make_table_entries_helper and make_column_defs_helper
which recursively process the model instances and their fields to generate the table data and column definitions.
for model fields these functions check if the nested model has functions make_columns or make_column_defs
which are called to generate the column data and definitions, respectively
These functions do not exist by default but can be used by models to modify the default definitions
where they dont work. This is typically the case if the models contain huge data sets that
we do not want to display in the table. An example of this is in the Molecule class.
Args:
model_instances: List of model instances to process
table_name: Name for the table (default: None, uses class name)
max_recursion_level: Maximum depth for processing nested models (default: 1)
drop_id: Whether to drop the ID field (default: True)
Returns:
dict: Dictionary with 'tableName', 'rowData' (for AG Grid), and 'columnDefs'
"""
if not model_instances:
return {
"tableName": table_name or "EmptyTable",
"rowData": [],
"columnDefs": [],
}
# Get the first instance and its class
first_instance = model_instances[0]
model_class = type(first_instance)
# Use the new helper functions
# table_data_result = make_table_entries_helper(
# first_instance, table_name, max_recursion_level, drop_id
# )
# Get column definitions using the model class
column_defs = make_column_defs_helper(
model_class, table_name, max_recursion_level, drop_id
)
# Process all instances to get row data
row_data = []
for instance in model_instances:
instance_result = make_table_entries_helper(
instance, table_name, max_recursion_level, drop_id
)
row_data.append(instance_result)
return {"tableName": "Table", "rowData": row_data, "columnDefs": column_defs}
[docs]
def create_column_def(field_name, field_type, field_value, field_path):
"""
Create a column definition for AG Grid based on field type and value.
Args:
field_name: Name of the field
field_type: Type of the field from model definition
field_value: Actual value of the field (can be None for class-based definitions)
field_path: Full path to the field (for nested structures)
Returns:
dict: Column definition for AG Grid
"""
column_def = {
"headerName": field_name.capitalize(),
"field": field_path,
}
# Determine the column type based on field_value if available or field_type
if field_value is not None:
if isinstance(field_value, bool):
column_def["type"] = "booleanColumn"
elif isinstance(field_value, (int, float)):
column_def["type"] = "numericColumn"
elif isinstance(field_value, datetime):
column_def["type"] = "dateColumn"
elif isinstance(field_value, dict):
column_def["type"] = "objectColumn"
elif isinstance(field_value, list):
column_def["type"] = "arrayColumn"
else:
column_def["type"] = "textColumn"
else:
# Determine type from field_type if field_value is None
if field_type is bool or (
isinstance(field_type, type) and issubclass(field_type, bool)
):
column_def["type"] = "booleanColumn"
elif field_type in (int, float) or (
isinstance(field_type, type) and issubclass(field_type, (int, float))
):
column_def["type"] = "numericColumn"
elif hasattr(field_type, "__name__") and field_type.__name__ == "datetime":
column_def["type"] = "dateColumn"
else:
column_def["type"] = "textColumn"
return column_def
[docs]
def ui_add_table(ui_options: Dict[str, Any]) -> Dict[str, Any]:
"""
Add table options to the UI schema for AG Grid.
the class must then implement o make_table function calling make_table
:param ui_options:
:return: modified ui_options
"""
ui_options["ui:table"] = { # this goes into gridOptions
"pagination": True,
"paginationPageSize": 20,
"domLayout": "autoHeight",
"defaultColDef": {"flex": 1, "minWidth": 80, "resizable": True},
}
return ui_options # for chaining