[Feature] Scenario Creation (#78)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#78
This commit is contained in:
2025-09-02 08:06:18 +12:00
parent 7da3880a9b
commit 2babe7f7e2
14 changed files with 583 additions and 183 deletions

View File

@ -1,5 +1,6 @@
import re
import logging
import json
from typing import Union, List, Optional, Set, Dict, Any
from django.contrib.auth import get_user_model
@ -552,11 +553,13 @@ class PackageManager(object):
try:
res = AdditionalInformationConverter.convert(name, addinf_data)
res_cls_name = res.__class__.__name__
ai_data = json.loads(res.model_dump_json())
ai_data['uuid'] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data)
except:
logger.error(f"Failed to convert {name} with {addinf_data}")
new_add_inf[name].append(res.model_dump_json())
scen.additional_information = new_add_inf
scen.save()

View File

@ -20,6 +20,7 @@ from django.db import models, transaction
from django.db.models import JSONField, Count, Q, QuerySet
from django.utils import timezone
from django.utils.functional import cached_property
from envipy_additional_information import EnviPyModel
from model_utils.models import TimeStampedModel
from polymorphic.models import PolymorphicModel
from sklearn.metrics import precision_score, recall_score, jaccard_score
@ -2292,7 +2293,8 @@ class ApplicabilityDomain(EnviPathModel):
transformation = {
'rule': rule_data,
'reliability': rule_reliabilities[rule_idx],
# TODO
# We're setting it here to False, as we don't know whether "assess" is called during pathway
# prediction or from Model Page. For persisted Nodes this field will be overwritten at runtime
'is_predicted': False,
'local_compatibility': local_compatibilities[rule_idx],
'probability': preds[rule_idx].probability,
@ -2407,27 +2409,88 @@ class Scenario(EnviPathModel):
@staticmethod
@transaction.atomic
def create(package, name, description, date, type, additional_information):
def create(package: 'Package', name:str, description:str, scenario_date:str, scenario_type:str, additional_information: List['EnviPyModel']):
s = Scenario()
s.package = package
if name is None or name.strip() == '':
name = f"Scenario {Scenario.objects.filter(package=package).count() + 1}"
s.name = name
if description is not None and description.strip() != '':
s.description = description
s.date = date
s.type = type
s.additional_information = additional_information
if scenario_date is not None and scenario_date.strip() != '':
s.scenario_date = scenario_date
if scenario_type is not None and scenario_type.strip() != '':
s.scenario_type = scenario_type
add_inf = defaultdict(list)
for info in additional_information:
cls_name = info.__class__.__name__
ai_data = json.loads(info.model_dump_json())
ai_data['uuid'] = f"{uuid4()}"
add_inf[cls_name].append(ai_data)
s.additional_information = add_inf
s.save()
return s
def add_additional_information(self, data):
pass
@transaction.atomic
def add_additional_information(self, data: 'EnviPyModel'):
cls_name = data.__class__.__name__
ai_data = json.loads(data.model_dump_json())
ai_data['uuid'] = f"{uuid4()}"
def remove_additional_information(self, data):
pass
if cls_name not in self.additional_information:
self.additional_information[cls_name] = []
def set_additional_information(self, data):
pass
self.additional_information[cls_name].append(ai_data)
self.save()
@transaction.atomic
def remove_additional_information(self, ai_uuid):
found_type = None
found_idx = -1
for k, vals in self.additional_information.items():
for i, v in enumerate(vals):
if v['uuid'] == ai_uuid:
found_type = k
found_idx = i
break
if found_type is not None and found_idx >= 0:
if len(self.additional_information[found_type]) == 1:
del self.additional_information[k]
else:
self.additional_information[k].pop(found_idx)
self.save()
else:
raise ValueError(f"Could not find additional information with uuid {ai_uuid}")
@transaction.atomic
def set_additional_information(self, data: Dict[str, 'EnviPyModel']):
new_ais = defaultdict(list)
for k, vals in data.items():
for v in vals:
ai_data = json.loads(v.model_dump_json())
if hasattr(v, 'uuid'):
ai_data['uuid'] = str(v.uuid)
else:
ai_data['uuid'] = str(uuid4())
new_ais[k].append(ai_data)
self.additional_information = new_ais
self.save()
def get_additional_information(self):
from envipy_additional_information import NAME_MAPPING
@ -2437,7 +2500,14 @@ class Scenario(EnviPathModel):
continue
for v in vals:
yield NAME_MAPPING[k](**json.loads(v))
# Per default additional fields are ignored
MAPPING = {c.__name__: c for c in NAME_MAPPING.values()}
inst = MAPPING[k](**v)
# Add uuid to uniquely identify objects for manipulation
if 'uuid' in v:
inst.__dict__['uuid'] = v['uuid']
yield inst
class UserSettingPermission(Permission):

View File

@ -4,15 +4,14 @@ from typing import List, Dict, Any
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.db.models import F, Value
from django.db.models.fields import CharField
from django.db.models.functions import Concat
from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest
from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
from envipy_additional_information import NAME_MAPPING
from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required
from utilities.misc import HTMLGenerator
from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager, EPDBURLParser
from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \
EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \
@ -1726,7 +1725,7 @@ def package_scenarios(request, package_uuid):
if request.method == 'GET':
if 'application/json' in request.META.get('HTTP_ACCEPT') and not request.GET.get('all', None):
if 'application/json' in request.META.get('HTTP_ACCEPT') and not request.GET.get('all', False):
scens = Scenario.objects.filter(package=current_package).order_by('name')
res = [{'name': s.name, 'url': s.url, 'uuid': s.uuid} for s in scens]
return JsonResponse(res, safe=False)
@ -1757,8 +1756,57 @@ def package_scenarios(request, package_uuid):
context['reviewed_objects'] = reviewed_scenario_qs
context['unreviewed_objects'] = unreviewed_scenario_qs
return render(request, 'collections/objects_list.html', context)
from envipy_additional_information import SLUDGE_ADDITIONAL_INFORMATION, SOIL_ADDITIONAL_INFORMATION, \
SEDIMENT_ADDITIONAL_INFORMATION
context['scenario_types'] = {
'Soil Data': {
'name': 'soil',
'widgets': [HTMLGenerator.generate_html(ai, prefix=f'soil_{0}') for ai in
[x for s in SOIL_ADDITIONAL_INFORMATION.values() for x in s]]
},
'Sludge Data': {
'name': 'sludge',
'widgets': [HTMLGenerator.generate_html(ai, prefix=f'sludge_{0}') for ai in
[x for s in SLUDGE_ADDITIONAL_INFORMATION.values() for x in s]]
},
'Water-Sediment System Data': {
'name': 'sediment',
'widgets': [HTMLGenerator.generate_html(ai, prefix=f'sediment_{0}') for ai in
[x for s in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in s]]
}
}
context['sludge_additional_information'] = SLUDGE_ADDITIONAL_INFORMATION
context['soil_additional_information'] = SOIL_ADDITIONAL_INFORMATION
context['sediment_additional_information'] = SEDIMENT_ADDITIONAL_INFORMATION
return render(request, 'collections/objects_list.html', context)
elif request.method == 'POST':
log_post_params(request)
scenario_name = request.POST.get('scenario-name')
scenario_description = request.POST.get('scenario-description')
scenario_date_year = request.POST.get('scenario-date-year')
scenario_date_month = request.POST.get('scenario-date-month')
scenario_date_day = request.POST.get('scenario-date-day')
scenario_date = scenario_date_year
if scenario_date_month is not None and scenario_date_month.strip() != '':
scenario_date += f'-{int(scenario_date_month):02d}'
if scenario_date_day is not None and scenario_date_day.strip() != '':
scenario_date += f'-{int(scenario_date_day):02d}'
scenario_type = request.POST.get('scenario-type')
additional_information = HTMLGenerator.build_models(request.POST.dict())
additional_information = [x for s in additional_information.values() for x in s]
s = Scenario.create(current_package, name=scenario_name, description=scenario_description,
scenario_date=scenario_date, scenario_type=scenario_type,
additional_information=additional_information)
return redirect(s.url)
else:
return HttpResponseNotAllowed(['GET', ])
@ -1779,10 +1827,63 @@ def package_scenario(request, package_uuid, scenario_uuid):
context['scenario'] = current_scenario
available_add_infs = []
for add_inf in NAME_MAPPING.values():
available_add_infs.append({
'display_name': add_inf.property_name(None),
'name': add_inf.__name__,
'widget': HTMLGenerator.generate_html(add_inf, prefix=f'{0}')
})
context['available_additional_information'] = available_add_infs
context['update_widgets'] = [HTMLGenerator.generate_html(ai, prefix=f'{i}') for i, ai in enumerate(current_scenario.get_additional_information())]
return render(request, 'objects/scenario.html', context)
elif request.method == 'POST':
log_post_params(request)
if hidden := request.POST.get('hidden', None):
if hidden == 'delete':
current_scenario.delete()
return redirect(current_package.url + '/scenario')
elif hidden == 'delete-additional-information':
uuid = request.POST.get('uuid')
current_scenario.remove_additional_information(uuid)
return redirect(current_scenario.url)
elif hidden == 'delete-all-additional-information':
current_scenario.additional_information = dict()
current_scenario.save()
return redirect(current_scenario.url)
elif hidden == 'set-additional-information':
ais = HTMLGenerator.build_models(request.POST.dict())
if s.DEBUG:
logger.info(ais)
current_scenario.set_additional_information(ais)
return redirect(current_scenario.url)
elif hidden == 'add-additional-information':
ais = HTMLGenerator.build_models(request.POST.dict())
if len(ais.keys()) != 1:
raise ValueError('Only one additional information field can be added at a time.')
ai = list(ais.values())[0][0]
if s.DEBUG:
logger.info(ais)
current_scenario.add_additional_information(ai)
return redirect(current_scenario.url)
else:
return HttpResponseNotAllowed(['GET', ])
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
else:
return HttpResponseNotAllowed(['GET', 'POST'])
##############

View File

@ -29,4 +29,4 @@ dependencies = [
[tool.uv.sources]
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.0" }
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git" }
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.4"}

View File

@ -1,6 +1,6 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_pathway_modal">
<a role="button" data-toggle="modal" data-target="#new_scenario_modal">
<span class="glyphicon glyphicon-plus"></span> New Scenario</a>
</li>
{% endif %}

View File

@ -1,2 +1,14 @@
{% if meta.can_edit %}
<li>
<a class="button" data-toggle="modal" data-target="#add_additional_information_modal">
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a>
</li>
<li>
<a class="button" data-toggle="modal" data-target="#update_scenario_additional_information_modal">
<i class="glyphicon glyphicon-trash"></i> Set Additional Information</a>
</li>
<li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a>
</li>
{% endif %}

View File

@ -1,115 +1,95 @@
<div class="modal fade" tabindex="-1" id="new_scenario_modal" role="dialog" aria-labelledby="newScenGenMod"
<div class="modal fade" tabindex="-1" id="new_scenario_modal" role="dialog" aria-labelledby="new_scenario_modal"
aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span> <span
class="sr-only">Close</span>
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="js-title-step"></h4>
<h4 class="modal-title">New Scenario</h4>
</div>
<form id="base-scenario-form" accept-charset="UTF-8" action="" data-remote="true" method="POST">
<div class="modal-body hide" data-step="1" data-title="New Scenario - Step 1">
<div class="jumbotron">Please enter name, description,
and date of scenario. Date should be associated to the
data, not the current date. For example, this could
reflect the publishing date of a study. You can leave
all fields but the name empty and fill them in
later.
<div class="modal-body">
<form id="new_scenario_form" accept-charset="UTF-8" action="{{ meta.current_package.url }}/scenario"
data-remote="true" method="post">
{% csrf_token %}
<div class="jumbotron">Please enter name, description, and date of scenario. Date should be
associated to the data, not the current date. For example, this could reflect the publishing
date of a study. You can leave all fields but the name empty and fill them in later.
<a target="_blank" href="https://wiki.envipath.org/index.php/scenario" role="button">wiki
&gt;&gt;</a>
</div>
<label for="name">Name</label>
<input id="name" name="studyname" placeholder="Name" class="form-control"/>
<label for="name">Description</label>
<input id="description" name="studydescription" placeholder="Description" class="form-control"/>
<label for="scenario-name">Name</label>
<input id="scenario-name" name="scenario-name" class="form-control" placeholder="Name"/>
<label for="scenario-description">Description</label>
<input id="scenario-description" name="scenario-description" class="form-control"
placeholder="Description"/>
<label id="dateField" for="dateYear">Date</label>
<table>
<tr>
<th>
<input type="number" id="dateYear" name="dateYear" class="form-control" placeholder="YYYY">
<input type="number" id="dateYear" name="scenario-date-year" class="form-control"
placeholder="YYYY">
</th>
<th>
<input type="number" id="dateMonth" name="dateMonth" min="1" max="12"
<input type="number" id="dateMonth" name="scenario-date-month" min="1" max="12"
class="form-control" placeholder="MM" align="">
</th>
<th>
<input type="number" id="dateDay" name="dateDay" min="1" max="31" class="form-control"
<input type="number" id="dateDay" name="scenario-date-day" min="1" max="31" class="form-control"
placeholder="DD">
</th>
</tr>
</table>
<label for="scenario-type">Scenario Type</label>
<select id="scenario-type" name="scenario-type" class="form-control" data-width='100%'>
<option value="empty" selected>Empty Scenario</option>
{% for k, v in scenario_types.items %}
<option value="{{ v.name }}">{{ k }}</option>
{% endfor %}
</select>
{% for type in scenario_types.values %}
<div id="{{ type.name }}-specific-inputs">
{% for widget in type.widgets %}
{{ widget|safe }}
{% endfor %}
</div>
<div class="modal-body hide" data-step="2" data-title="New Scenario - Step 2">
<div class="jumbotron">
Do you want to create an empty scenario and fill it
with your own set of attributes, or fill in a
pre-defined set of attributes for soil, sludge or sediment
data?
</div>
<div class="radio">
<label>
<input type="radio" name="type" id="radioEmpty" checked>Empty Scenario
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="type" id="radioSoil" value="soil" >Soil Data
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="type" id="radioSludge" value="sludge">Sludge Data
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="type" id="radioSediment" value="sediment">Water-Sediment System Data
</label>
</div>
</div>
<div class="modal-body hide" data-step="3" data-title="New Scenario - Step 3">
<div class="jumbotron" id="finaljumbo">
All done! Click Submit!
</div>
<div style="float: left"><button
id="addColumnButton" type="button"
class="btn btn-default">Add
another Scenario
</button></div>
<input type="hidden" name="fullScenario" value="true"/>
{% include "tables/scenario.html" %}
</div>
{% endfor %}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default js-btn-step pull-left" data-orientation="cancel" data-dismiss="modal"></button>
<button type="button" class="btn btn-default js-btn-step" data-orientation="previous"></button>
<button type="button" class="btn btn-default js-btn-step"
data-orientation="next" id="nextbutton"></button>
<a id="new_scenario_modal_form_submit" class="btn btn-primary" href="#">Submit</a>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<p></p>
<div id="scenariocontent"></div>
<!--Template index -->
<script language="javascript">
$(function() {
// Hide additional columns per default
$('.col-2').hide();
$('.col-3').hide();
//TODO just to see modal
$('#new_scenario_modal').modalSteps({
btnCancelHtml: 'Cancel',
btnPreviousHtml: 'Previous',
btnNextHtml: 'Next',
btnLastStepHtml: 'Submit',
disableNextButton: false,
<script>
$(function () {
// Initially hide all "specific" forms
$("div[id$='-specific-inputs']").each(function () {
$(this).hide();
});
});
// On change hide all and show only selected
$("#scenario-type").change(function () {
$("div[id$='-specific-inputs']").each(function () {
$(this).hide();
});
val = $('option:selected', this).val();
$("#" + val + "-specific-inputs").show();
});
$('#new_scenario_modal_form_submit').on('click', function (e) {
e.preventDefault();
$('#new_scenario_form').submit();
});
});
</script>

View File

@ -0,0 +1,61 @@
{% load static %}
<!-- Add Additional Information-->
<div id="add_additional_information_modal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="modal-title">Add Additional Information</h3>
</div>
<div class="modal-body">
<select id="select-additional-information-type" data-actions-box='true' class="form-control" data-width='100%'>
<option selected disabled>Select the type to add</option>
{% for add_inf in available_additional_information %}
<option value="{{ add_inf.name }}">{{ add_inf.display_name }}</option>
{% endfor %}
</select>
{% for add_inf in available_additional_information %}
<div class="aiform {{ add_inf.name }}" style="display: none;">
<form id="add_{{ add_inf.name }}_add-additional-information-modal-form" accept-charset="UTF-8"
action="" data-remote="true" method="post">
{% csrf_token %}
{{ add_inf.widget|safe }}
<input type="hidden" name="hidden" value="add-additional-information">
</form>
</div>
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="add-additional-information-modal-submit">Add
</button>
</div>
</div>
</div>
</div>
<script>
$(function() {
$('#select-additional-information-type').change(function(e){
var selectedType = $("#select-additional-information-type :selected").val();
$('.aiform').hide();
$('.' + selectedType).show();
})
$('#add-additional-information-modal-submit').click(function(e){
e.preventDefault();
var selectedType = $("#select-additional-information-type :selected").val();
console.log(selectedType);
if (selectedType !== null && selectedType !== undefined && selectedType !== '') {
$('.' + selectedType + ' >form').submit();
}
});
});
</script>

View File

@ -0,0 +1,39 @@
{% load static %}
<!-- Update Scenario Additional Information-->
<div id="update_scenario_additional_information_modal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="modal-title">Update Additional Information</h3>
</div>
<div class="modal-body">
<form id="edit-scenario-additional-information-modal-form" accept-charset="UTF-8" action="" data-remote="true" method="post">
{% csrf_token %}
{% for widget in update_widgets %}
{{ widget|safe }}
{% endfor %}
<input type="hidden" name="hidden" value="set-additional-information">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="edit-scenario-additional-information-modal-submit">Update</button>
</div>
</div>
</div>
</div>
<script>
$(function() {
$('#edit-scenario-additional-information-modal-submit').click(function(e){
e.preventDefault();
$('#edit-scenario-additional-information-modal-form').submit();
});
});
</script>

View File

@ -3,6 +3,8 @@
{% block content %}
{% block action_modals %}
{% include "modals/objects/add_additional_information_modal.html" %}
{% include "modals/objects/update_scenario_additional_information_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %}
<div class="panel-group" id="scenario-detail">
@ -24,6 +26,18 @@
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Description</div>
<div class="panel-body">
{{ scenario.description }}
<br>
{{ scenario.scenario_type }}
<br>
Reported {{ scenario.scenario_date }}
</div>
</div>
<div class="table-responsive">
<table id="scenario-table" class="table table-bordered table-striped table-hover">
<tbody>
@ -31,18 +45,42 @@
<th>Property</th>
<th>Value</th>
<th>Unit</th>
{% if meta.can_edit %}
<th>Remove</th>
{% endif %}
</tr>
{% for ai in scenario.get_additional_information %}
<tr>
<td>{{ ai.property_name|safe }} </td>
<td> {{ ai.property_name|safe }} </td>
<td> {{ ai.property_data|safe }} </td>
<td> {{ ai.property_unit|safe }} </td>
<td></td>
{% if meta.can_edit %}
<td>
<form action="{% url 'package scenario detail' scenario.package.uuid scenario.uuid %}" method="post">
{% csrf_token %}
<input type="hidden" name="uuid" value="{{ ai.uuid }}">
<input type="hidden" name="hidden" value="delete-additional-information">
<button type="submit" class="btn"><span class="glyphicon glyphicon-minus"></span></button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
{% if meta.can_edit %}
<tr>
<td></td>
<td></td>
<td>Delete all</td>
<td>
<form action="{% url 'package scenario detail' scenario.package.uuid scenario.uuid %}" method="post">
{% csrf_token %}
<input type="hidden" name="hidden" value="delete-all-additional-information">
<button type="submit" class="btn"><span class="glyphicon glyphicon-trash"></span></button>
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>

View File

@ -1,72 +0,0 @@
{% for obj in available_additional_information.types %}
<div id="table-{{obj.type}}-Div">
<table class="table table-bordered table-hover">
<tbody>
<tr id="{{ obj.type }}GroupRow" style="background-color: rgba(0, 0, 0, 0.08);">
<td><p style="font-size:18px">{{obj.title}}</p></td>
</tr>
<!-- Loop through all AIs and attach the ones without subtype -->
{% for ai in available_additional_information.ais %}
<tr>
{% if obj.type in ai.types and ai.sub_type is not defined %}
<td><span title="">{{ ai.name }}</span></td>
<!-- #TODO -->
{% for c in "1 2 3"|make_list %}
<td class="col-{{ c }}">
{% for form in ai.forms %}
<!-- Build input -->
{% if form.type == 'select' %}
<select class="form-control" name="{{ form.param_name}}">
<option value="">{{ form.placeholder }}</option>
{% for choice in form.choices %}
<option value="{{ choice.value }}">
{{ choice.name }}
</option>
{% endfor %}
</select>
{% else %}
<input type="{{ form.type }}" name="{{ form.param_name }}" class="form-control" placeholder="{{ form.placeholder|safe }}"/>
{% endif %}
{% endfor %}
</td>
{% endfor %}
{% endif %}
{% endfor %}
</tr>
{% for subtype in available_additional_information.subtypes %}
<tr id="{{ subtype.type }}GroupRow" style="background-color: rgba(0, 0, 0, 0.08);">
<td><p style="font-size:18px">{{subtype.title}}</p></td>
</tr>
<!-- Loop through all AIs and attach the ones with the same subtype -->
{% for ai in available_additional_information.ais %}
<tr>
{% if obj.type in ai.types and subtype.type == ai.sub_type %}
<td><span title="">{{ ai.name }}</span></td>
<!-- #TODO -->
{% for c in "1 2 3"|make_list %}
<td class="col-{{ c }}">
{% for form in ai.forms %}
<!-- Build input -->
{% if form.type == 'select' %}
<select class="form-control" name="{{ form.param_name }}">
<option value="">{{ form.placeholder }}</option>
{% for choice in form.choices %}
<option value="{{ choice.value }}">
{{ choice.name }}
</option>
{% endfor %}
</select>
{% else %}
<input type="{{ form.type }}" name="{{ form.param_name }}" class="form-control" placeholder="{{ form.placeholder|safe }}">
{% endif %}
{% endfor %}
</td>
{% endfor %}
{% endif %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}

168
utilities/misc.py Normal file
View File

@ -0,0 +1,168 @@
import html
import logging
from collections import defaultdict
from enum import Enum
from types import NoneType
from typing import Dict, List, Any
from envipy_additional_information import Interval, EnviPyModel
from envipy_additional_information import NAME_MAPPING
from pydantic import BaseModel, HttpUrl
logger = logging.getLogger(__name__)
class HTMLGenerator:
registry = {x.__name__: x for x in NAME_MAPPING.values()}
@staticmethod
def generate_html(additional_information: 'EnviPyModel', prefix='') -> str:
from typing import get_origin, get_args, Union
if isinstance(additional_information, type):
clz_name = additional_information.__name__
else:
clz_name = additional_information.__class__.__name__
widget = f'<h4>{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:
widget += f"""
<div class="form-group row">
<div class="col-md-6">
<label for="{full_name}__start">{' '.join([x.capitalize() for x in name.split('_')])} Start</label>
<input type="number" class="form-control" id="{full_name}__start" name="{full_name}__start" value="{value.start if value else ''}">
</div>
<div class="col-md-6">
<label for="{full_name}__end">{' '.join([x.capitalize() for x in name.split('_')])} End</label>
<input type="number" class="form-control" 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>'
widget += f"""
<div class="form-group">
<label for="{full_name}">{' '.join([x.capitalize() for x in name.split('_')])}</label>
<select class="form-control" id="{full_name}" name="{full_name}">
<option value="" disabled selected>Select {' '.join([x.capitalize() for x in name.split('_')])}</option>
{options}
</select>
</div>
"""
else:
if field_type == str or field_type == HttpUrl:
input_type = 'text'
elif field_type == float or field_type == int:
input_type = 'number'
elif field_type == 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 != bool else ''
widget += f"""
<div class="form-group">
<label for="{full_name}">{' '.join([x.capitalize() for x in name.split('_')])}</label>
<input type="{input_type}" class="form-control" id="{full_name}" name="{full_name}" value="{value_to_use}" {"checked" if value and field_type == bool else ""}>
</div>
"""
return widget + "<hr>"
@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

4
uv.lock generated
View File

@ -426,7 +426,7 @@ requires-dist = [
{ name = "django-ninja", specifier = ">=1.4.1" },
{ name = "django-polymorphic", specifier = ">=4.1.0" },
{ name = "enviformer", git = "ssh://git@git.envipath.com/enviPath/enviformer.git?rev=v0.1.0" },
{ name = "envipy-additional-information", git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git" },
{ name = "envipy-additional-information", git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.1.4" },
{ name = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
{ name = "epam-indigo", specifier = ">=1.30.1" },
{ name = "gunicorn", specifier = ">=23.0.0" },
@ -443,7 +443,7 @@ requires-dist = [
[[package]]
name = "envipy-additional-information"
version = "0.1.0"
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git#4804b24b3479bed6108a49e4401bff8947c03cbd" }
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.1.4#4da604090bf7cf1f3f552d69485472dbc623030a" }
dependencies = [
{ name = "pydantic" },
]