[Feature] Dynamic additional information rendering in frontend (#282)

This implements a version of #274, relying on Pydantics built in JSON schema and JSON rendering.
Requires additional UI tagging in the ai model repo but will remove HTML tags.

Example scenario with filled information: 5882df9c-dae1-4d80-a40e-db4724271456/scenario/3a4d395a-6a6d-4154-8ce3-ced667fceec0

Reviewed-on: enviPath/enviPy#282
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2026-01-31 00:44:03 +13:00
committed by jebus
parent 9f63a9d4de
commit d80dfb5ee3
42 changed files with 3732 additions and 609 deletions

View File

@ -1,20 +1,15 @@
import base64
import hashlib
import hmac
import html
import json
import logging
import uuid
from collections import defaultdict
from datetime import datetime
from enum import Enum
from types import NoneType
from typing import Any, Dict, List, TYPE_CHECKING
from django.conf import settings as s
from django.db import transaction
from envipy_additional_information import NAME_MAPPING, EnviPyModel, Interval
from pydantic import BaseModel, HttpUrl
from epdb.models import (
Compound,
@ -49,183 +44,6 @@ if TYPE_CHECKING:
from epdb.logic import SPathway
class HTMLGenerator:
registry = {x.__name__: x for x in NAME_MAPPING.values()}
@staticmethod
def generate_html(additional_information: "EnviPyModel", prefix="") -> str:
from typing import Union, get_args, get_origin
if isinstance(additional_information, type):
clz_name = additional_information.__name__
else:
clz_name = additional_information.__class__.__name__
widget = f'<h4 class="h4 font-semibold mt-2 mb-1">{clz_name}</h4>'
if hasattr(additional_information, "uuid"):
uuid = additional_information.uuid
widget += f'<input type="hidden" name="{clz_name}__{prefix}__uuid" value="{uuid}">'
for name, field in additional_information.model_fields.items():
value = getattr(additional_information, name, None)
full_name = f"{clz_name}__{prefix}__{name}"
annotation = field.annotation
base_type = get_origin(annotation) or annotation
# Optional[Interval[float]] alias for Union[X, None]
if base_type is Union:
for arg in get_args(annotation):
if arg is not NoneType:
field_type = arg
break
else:
field_type = base_type
is_interval_float = (
field_type == Interval[float]
or str(field_type) == str(Interval[float])
or "Interval[float]" in str(field_type)
)
if is_interval_float:
label_text_start = " ".join([x.capitalize() for x in name.split("_")]) + " Start"
label_text_end = " ".join([x.capitalize() for x in name.split("_")]) + " End"
widget += f"""
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label" for="{full_name}__start">
<span class="label-text">{label_text_start}</span>
</label>
<input type="number" class="input input-bordered w-full" id="{full_name}__start" name="{full_name}__start" value="{value.start if value else ""}">
</div>
<div class="form-control">
<label class="label" for="{full_name}__end">
<span class="label-text">{label_text_end}</span>
</label>
<input type="number" class="input input-bordered w-full" id="{full_name}__end" name="{full_name}__end" value="{value.end if value else ""}">
</div>
</div>
"""
elif issubclass(field_type, Enum):
options: str = ""
for e in field_type:
options += f'<option value="{e.value}" {"selected" if e == value else ""}>{html.escape(e.name)}</option>'
label_text = " ".join([x.capitalize() for x in name.split("_")])
widget += f"""
<div class="form-control mb-4">
<label class="label" for="{full_name}">
<span class="label-text">{label_text}</span>
</label>
<select class="select select-bordered w-full" id="{full_name}" name="{full_name}">
<option value="" disabled selected>Select {label_text}</option>
{options}
</select>
</div>
"""
else:
if field_type is str or field_type is HttpUrl:
input_type = "text"
elif field_type is float or field_type is int:
input_type = "number"
elif field_type is bool:
input_type = "checkbox"
else:
raise ValueError(f"Could not parse field type {field_type} for {name}")
value_to_use = value if value and field_type is not bool else ""
label_text = " ".join([x.capitalize() for x in name.split("_")])
if field_type is bool:
widget += f"""
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">{label_text}</span>
<input type="checkbox" class="checkbox" id="{full_name}" name="{full_name}" {"checked" if value else ""}>
</label>
</div>
"""
else:
widget += f"""
<div class="form-control mb-4">
<label class="label" for="{full_name}">
<span class="label-text">{label_text}</span>
</label>
<input type="{input_type}" class="input input-bordered w-full" id="{full_name}" name="{full_name}" value="{value_to_use}">
</div>
"""
return widget
@staticmethod
def build_models(params) -> Dict[str, List["EnviPyModel"]]:
def has_non_none(d):
"""
Recursively checks if any value in a (possibly nested) dict is not None.
"""
for value in d.values():
if isinstance(value, dict):
if has_non_none(value): # recursive check
return True
elif value is not None:
return True
return False
"""
Build Pydantic model instances from flattened HTML parameters.
Args:
params: dict of {param_name: value}, e.g. form data
model_registry: mapping of class names (strings) to Pydantic model classes
Returns:
dict: {ClassName: [list of model instances]}
"""
grouped: Dict[str, Dict[str, Dict[str, Any]]] = {}
# Step 1: group fields by ClassName and Number
for key, value in params.items():
if value == "":
value = None
parts = key.split("__")
if len(parts) < 3:
continue # skip invalid keys
class_name, number, *field_parts = parts
grouped.setdefault(class_name, {}).setdefault(number, {})
# handle nested fields like interval__start
target = grouped[class_name][number]
current = target
for p in field_parts[:-1]:
current = current.setdefault(p, {})
current[field_parts[-1]] = value
# Step 2: instantiate Pydantic models
instances: Dict[str, List[BaseModel]] = defaultdict(list)
for class_name, number_dict in grouped.items():
model_cls = HTMLGenerator.registry.get(class_name)
if not model_cls:
logger.info(f"Could not find model class for {class_name}")
continue
for number, fields in number_dict.items():
if not has_non_none(fields):
print(f"Skipping empty {class_name} {number} {fields}")
continue
uuid = fields.pop("uuid", None)
instance = model_cls(**fields)
if uuid:
instance.__dict__["uuid"] = uuid
instances[class_name].append(instance)
return instances
class PackageExporter:
def __init__(
self,