[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 re
import logging import logging
import json
from typing import Union, List, Optional, Set, Dict, Any from typing import Union, List, Optional, Set, Dict, Any
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -552,11 +553,13 @@ class PackageManager(object):
try: try:
res = AdditionalInformationConverter.convert(name, addinf_data) 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: except:
logger.error(f"Failed to convert {name} with {addinf_data}") 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.additional_information = new_add_inf
scen.save() 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.db.models import JSONField, Count, Q, QuerySet
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from envipy_additional_information import EnviPyModel
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from sklearn.metrics import precision_score, recall_score, jaccard_score from sklearn.metrics import precision_score, recall_score, jaccard_score
@ -2292,7 +2293,8 @@ class ApplicabilityDomain(EnviPathModel):
transformation = { transformation = {
'rule': rule_data, 'rule': rule_data,
'reliability': rule_reliabilities[rule_idx], '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, 'is_predicted': False,
'local_compatibility': local_compatibilities[rule_idx], 'local_compatibility': local_compatibilities[rule_idx],
'probability': preds[rule_idx].probability, 'probability': preds[rule_idx].probability,
@ -2407,27 +2409,88 @@ class Scenario(EnviPathModel):
@staticmethod @staticmethod
@transaction.atomic @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 = Scenario()
s.package = package s.package = package
if name is None or name.strip() == '':
name = f"Scenario {Scenario.objects.filter(package=package).count() + 1}"
s.name = name s.name = name
s.description = description
s.date = date if description is not None and description.strip() != '':
s.type = type s.description = description
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() s.save()
return s return s
def add_additional_information(self, data): @transaction.atomic
pass 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): if cls_name not in self.additional_information:
pass self.additional_information[cls_name] = []
def set_additional_information(self, data): self.additional_information[cls_name].append(ai_data)
pass 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): def get_additional_information(self):
from envipy_additional_information import NAME_MAPPING from envipy_additional_information import NAME_MAPPING
@ -2437,7 +2500,14 @@ class Scenario(EnviPathModel):
continue continue
for v in vals: 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): class UserSettingPermission(Permission):

View File

@ -4,15 +4,14 @@ from typing import List, Dict, Any
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model 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.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from envipy_additional_information import NAME_MAPPING
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
from utilities.misc import HTMLGenerator
from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager, EPDBURLParser from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager, EPDBURLParser
from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \ from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \
EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \ EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \
@ -1726,7 +1725,7 @@ def package_scenarios(request, package_uuid):
if request.method == 'GET': 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') scens = Scenario.objects.filter(package=current_package).order_by('name')
res = [{'name': s.name, 'url': s.url, 'uuid': s.uuid} for s in scens] res = [{'name': s.name, 'url': s.url, 'uuid': s.uuid} for s in scens]
return JsonResponse(res, safe=False) return JsonResponse(res, safe=False)
@ -1757,8 +1756,57 @@ def package_scenarios(request, package_uuid):
context['reviewed_objects'] = reviewed_scenario_qs context['reviewed_objects'] = reviewed_scenario_qs
context['unreviewed_objects'] = unreviewed_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: else:
return HttpResponseNotAllowed(['GET', ]) return HttpResponseNotAllowed(['GET', ])
@ -1779,10 +1827,63 @@ def package_scenario(request, package_uuid, scenario_uuid):
context['scenario'] = current_scenario 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) 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 HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
else: else:
return HttpResponseNotAllowed(['GET', ]) return HttpResponseNotAllowed(['GET', 'POST'])
############## ##############

View File

@ -29,4 +29,4 @@ dependencies = [
[tool.uv.sources] [tool.uv.sources]
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.0" } 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-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 %} {% if meta.can_edit %}
<li> <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> <span class="glyphicon glyphicon-plus"></span> New Scenario</a>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,2 +1,14 @@
{% if meta.can_edit %} {% 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 %} {% 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"> aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal"> <button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span> <span <span aria-hidden="true">&times;</span>
class="sr-only">Close</span> <span class="sr-only">Close</span>
</button> </button>
<h4 class="js-title-step"></h4> <h4 class="modal-title">New Scenario</h4>
</div> </div>
<form id="base-scenario-form" accept-charset="UTF-8" action="" data-remote="true" method="POST"> <div class="modal-body">
<div class="modal-body hide" data-step="1" data-title="New Scenario - Step 1"> <form id="new_scenario_form" accept-charset="UTF-8" action="{{ meta.current_package.url }}/scenario"
<div class="jumbotron">Please enter name, description, data-remote="true" method="post">
and date of scenario. Date should be associated to the {% csrf_token %}
data, not the current date. For example, this could <div class="jumbotron">Please enter name, description, and date of scenario. Date should be
reflect the publishing date of a study. You can leave associated to the data, not the current date. For example, this could reflect the publishing
all fields but the name empty and fill them in date of a study. You can leave all fields but the name empty and fill them in later.
later. <a target="_blank" href="https://wiki.envipath.org/index.php/scenario" role="button">wiki
&gt;&gt;</a>
</div> </div>
<label for="name">Name</label> <label for="scenario-name">Name</label>
<input id="name" name="studyname" placeholder="Name" class="form-control"/> <input id="scenario-name" name="scenario-name" class="form-control" placeholder="Name"/>
<label for="name">Description</label> <label for="scenario-description">Description</label>
<input id="description" name="studydescription" placeholder="Description" class="form-control"/> <input id="scenario-description" name="scenario-description" class="form-control"
placeholder="Description"/>
<label id="dateField" for="dateYear">Date</label> <label id="dateField" for="dateYear">Date</label>
<table> <table>
<tr> <tr>
<th> <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>
<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=""> class="form-control" placeholder="MM" align="">
</th> </th>
<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"> placeholder="DD">
</th> </th>
</tr> </tr>
</table> </table>
</div> <label for="scenario-type">Scenario Type</label>
<div class="modal-body hide" data-step="2" data-title="New Scenario - Step 2"> <select id="scenario-type" name="scenario-type" class="form-control" data-width='100%'>
<div class="jumbotron"> <option value="empty" selected>Empty Scenario</option>
Do you want to create an empty scenario and fill it {% for k, v in scenario_types.items %}
with your own set of attributes, or fill in a <option value="{{ v.name }}">{{ k }}</option>
pre-defined set of attributes for soil, sludge or sediment {% endfor %}
data? </select>
</div>
<div class="radio"> {% for type in scenario_types.values %}
<label> <div id="{{ type.name }}-specific-inputs">
<input type="radio" name="type" id="radioEmpty" checked>Empty Scenario {% for widget in type.widgets %}
</label> {{ widget|safe }}
</div> {% endfor %}
<div class="radio"> </div>
<label> {% endfor %}
<input type="radio" name="type" id="radioSoil" value="soil" >Soil Data
</label> </form>
</div> </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>
</form>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-default js-btn-step pull-left" data-orientation="cancel" data-dismiss="modal"></button> <a id="new_scenario_modal_form_submit" class="btn btn-primary" href="#">Submit</a>
<button type="button" class="btn btn-default js-btn-step" data-orientation="previous"></button> <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-default js-btn-step"
data-orientation="next" id="nextbutton"></button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<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();
});
<p></p> $('#new_scenario_modal_form_submit').on('click', function (e) {
<div id="scenariocontent"></div> e.preventDefault();
$('#new_scenario_form').submit();
});
<!--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> </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 content %}
{% block action_modals %} {% 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" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}
<div class="panel-group" id="scenario-detail"> <div class="panel-group" id="scenario-detail">
@ -24,6 +26,18 @@
</div> </div>
</div> </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"> <div class="table-responsive">
<table id="scenario-table" class="table table-bordered table-striped table-hover"> <table id="scenario-table" class="table table-bordered table-striped table-hover">
<tbody> <tbody>
@ -31,18 +45,42 @@
<th>Property</th> <th>Property</th>
<th>Value</th> <th>Value</th>
<th>Unit</th> <th>Unit</th>
{% if meta.can_edit %}
<th>Remove</th> <th>Remove</th>
{% endif %}
</tr> </tr>
{% for ai in scenario.get_additional_information %} {% for ai in scenario.get_additional_information %}
<tr> <tr>
<td>{{ ai.property_name|safe }} </td> <td> {{ ai.property_name|safe }} </td>
<td> {{ ai.property_data|safe }} </td> <td> {{ ai.property_data|safe }} </td>
<td> {{ ai.property_unit|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> </tr>
{% endfor %} {% 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> </tbody>
</table> </table>
</div> </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-ninja", specifier = ">=1.4.1" },
{ name = "django-polymorphic", specifier = ">=4.1.0" }, { name = "django-polymorphic", specifier = ">=4.1.0" },
{ name = "enviformer", git = "ssh://git@git.envipath.com/enviPath/enviformer.git?rev=v0.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 = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
{ name = "epam-indigo", specifier = ">=1.30.1" }, { name = "epam-indigo", specifier = ">=1.30.1" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },
@ -443,7 +443,7 @@ requires-dist = [
[[package]] [[package]]
name = "envipy-additional-information" name = "envipy-additional-information"
version = "0.1.0" 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 = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]