forked from enviPath/enviPy
[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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user