From e3efb80af8c33002f8bcbee4062fc828f03c2282 Mon Sep 17 00:00:00 2001 From: stefansc1 Date: Tue, 21 Oct 2025 16:55:10 +0200 Subject: [PATCH 1/2] add AssetTemplate --- app/projects/admin.py | 8 ++ app/projects/migrations/0026_assettemplate.py | 70 +++++++++++++++++ app/projects/models/base_models.py | 26 +++++++ app/projects/urls.py | 8 +- app/projects/views.py | 67 +++++++++++++++- app/static/js/grid_model_topology.js | 78 ++++++++++++++++++- app/templates/asset/asset_create_form.html | 34 ++++++++ app/templates/scenario/scenario_step2.html | 1 + 8 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 app/projects/migrations/0026_assettemplate.py diff --git a/app/projects/admin.py b/app/projects/admin.py index 995acb73..c1c099e4 100644 --- a/app/projects/admin.py +++ b/app/projects/admin.py @@ -10,3 +10,11 @@ admin.site.register(Asset) admin.site.register(Bus) admin.site.register(ConnectionLink) + + +class AssetTemplateAdmin(admin.ModelAdmin): + list_display = ("id", "visibility", "name", "asset_type") + list_filter = ("visibility", "asset_type") + + +admin.site.register(AssetTemplate, AssetTemplateAdmin) diff --git a/app/projects/migrations/0026_assettemplate.py b/app/projects/migrations/0026_assettemplate.py new file mode 100644 index 00000000..02839ad1 --- /dev/null +++ b/app/projects/migrations/0026_assettemplate.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.4 on 2025-10-21 11:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0025_connectionport"), + ] + + operations = [ + migrations.CreateModel( + name="AssetTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("desc", models.TextField(blank=True)), + ( + "visibility", + models.CharField( + choices=[ + ("project", "Project"), + ("account", "Account"), + ("global", "Everyone"), + ], + max_length=8, + ), + ), + ("created_ts", models.DateTimeField(auto_now_add=True)), + ("parameters", models.JSONField()), + ( + "asset_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="projects.assettype", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="projects.project", + ), + ), + ], + ), + ] diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index 9fd367e1..c8dd008d 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -472,6 +472,9 @@ def remove_field(self, field_name): temp.pop(temp.index(field_name)) self.asset_fields = "[" + ",".join(temp) + "]" + def __str__(self): + return self.asset_type + class TopologyNode(models.Model): name = models.CharField(max_length=60, null=False, blank=False) @@ -749,6 +752,29 @@ def is_input_timeseries_empty(self): return self.input_timeseries == "" +class AssetTemplate(models.Model): + VISIBILITY_CHOICES = [ + ("project", "Project"), + ("account", "Account"), + ("global", "Everyone"), + ] + name = models.CharField(max_length=255) + desc = models.TextField(blank=True) + project = models.ForeignKey( + Project, on_delete=models.SET_NULL, null=True, blank=True + ) + visibility = models.CharField(max_length=8, choices=VISIBILITY_CHOICES) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + created_ts = models.DateTimeField(auto_now_add=True) + asset_type = models.ForeignKey(AssetType, on_delete=models.CASCADE) + parameters = models.JSONField() + + class COPCalculator(models.Model): scenario = models.ForeignKey( diff --git a/app/projects/urls.py b/app/projects/urls.py index 16fb3b13..279567f4 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -155,7 +155,7 @@ name="get_constant_timeseries_id", ), re_path( - "get/constant/timeseries/id/(?P\d+)/value/(?P\d+(\.\d+)?)/$", + r"get/constant/timeseries/id/(?P\d+)/value/(?P\d+(\.\d+)?)/$", get_constant_timeseries_id, name="get_constant_timeseries_id", ), @@ -210,6 +210,12 @@ asset_cops_create_or_update, name="asset_cops_create_or_update", ), + # templates + path( + "project//template/", + template_get_or_create, + name="template_get_or_create", + ), # ParameterChangeTracker (track of simulated scenario changes) path( "reset_scenario_changes/", diff --git a/app/projects/views.py b/app/projects/views.py index d92f952b..e5cba7f3 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -21,7 +21,8 @@ from django.template.loader import get_template from jsonview.decorators import json_view -from django.db.models import Q +from django.db.models import Q, Value +from django.db.models.functions import Concat from epa.settings import MVS_GET_URL, MVS_LP_FILE_URL, MVS_SA_GET_URL from .forms import * from .requests import ( @@ -1618,7 +1619,27 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non ) return render(request, "asset/bus_create_form.html", {"form": form}) - elif asset_type_name in ["bess", "h2ess", "gess", "hess"]: + # collect available templates + asset_templates = AssetTemplate.objects.filter( + asset_type__asset_type=asset_type_name + ).order_by("created_ts") + # project templates + project_templates = asset_templates.filter( + visibility="project", project_id=scenario.project_id + ).annotate(display_name=Concat("name", Value(" (prj)"))) + # account templates + account_templates = asset_templates.filter( + visibility="account", created_by=request.user + ).annotate(display_name=Concat("name", Value(" (acc)"))) + # global templates + global_templates = asset_templates.filter(visibility="global").annotate( + display_name=Concat("name", Value(" (std)")) + ) + templates = AssetTemplate.objects.none().union( + project_templates, account_templates, global_templates + ) + + if asset_type_name in ["bess", "h2ess", "gess", "hess"]: if asset_uuid: existing_ess_asset = get_object_or_404(Asset, unique_id=asset_uuid) ess_asset_children = Asset.objects.filter( @@ -1669,7 +1690,14 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non input_output_mapping=input_output_mapping, initial={"name": default_name}, ) - return render(request, "asset/storage_asset_create_form.html", {"form": form}) + return render( + request, + "asset/storage_asset_create_form.html", + { + "form": form, + "templates": templates, + }, + ) else: # all other assets if asset_uuid: @@ -1703,6 +1731,7 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non context = { "form": form, + "templates": templates, "asset_type_name": asset_type_name, "input_timeseries_data": input_timeseries_data, "input_timeseries_timestamps": json.dumps( @@ -1845,6 +1874,38 @@ def asset_cops_create_or_update( # endregion Asset +# templates +@login_required +@require_http_methods(["GET", "POST"]) +def template_get_or_create(request, project_id): + if request.method == "GET": + template = get_object_or_404(AssetTemplate, id=int(request.GET.get("id"))) + # check permissions + if template.visibility == "project" and project_id != template.project_id: + raise Http404() + if template.visibility == "account" and request.user != template.created_by: + raise Http404() + # visibility = global needs no check + return JsonResponse(template.parameters) + + # POST: create new template + asset_type = get_object_or_404(AssetType, asset_type=request.POST["asset_type"]) + template = AssetTemplate.objects.create( + name=request.POST["name"], + desc=request.POST["desc"], + project_id=project_id, + visibility=request.POST["visibility"], + created_by=request.user, + asset_type=asset_type, + parameters=json.loads(request.POST["data"]), + ) + if request.POST["request_global"] == "true": + logger.warning( + f"AssetTemplate #{template.id} ({template.name}) should be made public" + ) + return HttpResponse(status=201) # created + + # region MVS JSON Related diff --git a/app/static/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index 1571e8ef..15a009a0 100644 --- a/app/static/js/grid_model_topology.js +++ b/app/static/js/grid_model_topology.js @@ -369,7 +369,7 @@ function submitForm() { postUrl += "/" + nodesToDB.get(topologyNodeId).uid; // send the form of the asset to be saved in database (projects/views.py::asset_create_or_update) - fetch(postUrl, { + return fetch(postUrl, { method: 'POST', headers: {'X-CSRFToken': csrfToken}, body: formData, @@ -390,11 +390,13 @@ function submitForm() { if(copCollapseDOM){ copCollapse.hide(); } + return Promise.resolve(); } else { // assign the content of the form to the form tag of the modal guiModalDOM.querySelector('form .modal-body').innerHTML = jsonRes.form_html; // make certain to show form guiModal.show(); + return Promise.reject(); } }).catch(error => { console.error(error); @@ -747,3 +749,77 @@ function computeCOP(event){ alert(error.message); }); } + +/* templates */ +function load_template(e) { + e.preventDefault(); + let template_select = document.getElementById('template-select'); + if (!template_select?.value) { + alert("No template selected"); + return; + } + let url = templateUrl + '?id=' + template_select.value; + const form = document.getElementById("assetForm"); + if (!form) { + console.error("Form not found"); + return; + } + fetch(url).then(response => response.json()).then(data => { + for (let [key, value] of Object.entries(data)) { + let inp = form[key]; + if (inp) + inp.value = value; + else + console.log("Skip input field '" + key + "' (not found)"); + } + updateInputTimeseries(); + alert("Template '" + template_select.selectedOptions[0].textContent + "' loaded"); + }).catch(e => { + console.error("Error fetching template", e); + }); +} + +function save_template(e) { + e.preventDefault(); + const assetFormData = new FormData(e.target.form); + let name = assetFormData.get("template-name"); + if (!name) { + alert("Need template name"); + return; + } + let desc = assetFormData.get("template-desc"); + let data = {}; + const ignoreKeys = [ + "name", "csrfmiddlewaretoken", + "template-name", "template-desc", "template-request_global", + ]; + assetFormData.forEach((value, key) => { + if (ignoreKeys.includes(key)) + return; + // only save string values, not objects (like files) + if (typeof value === "string") + data[key] = value; + }); + const templateFormData = new FormData(); + templateFormData.append('name', name); + templateFormData.append('desc', desc); + // visibility type from button name: template-project or template-account + templateFormData.append('visibility', e.target.name.substring(9)); + templateFormData.append('request_global', document.getElementById("chk-global")?.checked); + templateFormData.append('asset_type', guiModalDOM.getAttribute("data-node-type")); + templateFormData.append('data', JSON.stringify(data)); + submitForm().then(_ => { + fetch(templateUrl, { + method: 'POST', + headers: {'X-CSRFToken': csrfToken}, + body: templateFormData, + }).then(response => { + if (response.status != 201) + return Promise.reject(response.statusText); + // successfully created + alert("Template '" + name + "' saved"); + }).catch(e => { + console.error("Error saving template", e); + }); + }); +} diff --git a/app/templates/asset/asset_create_form.html b/app/templates/asset/asset_create_form.html index e704f24a..d1b15b3a 100644 --- a/app/templates/asset/asset_create_form.html +++ b/app/templates/asset/asset_create_form.html @@ -97,5 +97,39 @@

{% translate "Technical parameters" %}

{% endif %} +
+ {% translate "Template" %} +
+
+

{% translate "Use existing" %}

+ + +
+
+

{% translate "Create from asset" %}

+
+ + +
+
+ + +
+ + +
+
+
+
+
diff --git a/app/templates/scenario/scenario_step2.html b/app/templates/scenario/scenario_step2.html index 2e13f706..b96b680c 100644 --- a/app/templates/scenario/scenario_step2.html +++ b/app/templates/scenario/scenario_step2.html @@ -145,6 +145,7 @@

{{ group_names|get_item:group_name|title }}

const copPostUrl = `{% url 'asset_cops_create_or_update' scenario.id %}`; const tsGetUrl = `{% url 'get_timeseries' %}`; const findtsGetUrl = `{% url 'get_constant_timeseries_id' %}`; + const templateUrl = `{% url 'template_get_or_create' scenario.project_id %}`; From 71d78325a2fde0a34465ba24e4976e0634e6836e Mon Sep 17 00:00:00 2001 From: stefansc1 Date: Thu, 23 Oct 2025 12:41:23 +0200 Subject: [PATCH 2/2] template fixes - make templates available for storage assets - show templates even if saving asset failed --- app/projects/scenario_topology_helpers.py | 48 ++++++++++++++++--- app/projects/views.py | 37 +++++--------- app/templates/asset/asset_create_form.html | 36 +------------- app/templates/asset/asset_template.html | 36 ++++++++++++++ .../asset/storage_asset_create_form.html | 4 +- 5 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 app/templates/asset/asset_template.html diff --git a/app/projects/scenario_topology_helpers.py b/app/projects/scenario_topology_helpers.py index f73ff653..0d3e083c 100644 --- a/app/projects/scenario_topology_helpers.py +++ b/app/projects/scenario_topology_helpers.py @@ -5,6 +5,7 @@ from projects.models import ( Bus, AssetType, + AssetTemplate, Scenario, ConnectionLink, Asset, @@ -18,10 +19,13 @@ ) import json from django.core.exceptions import NON_FIELD_ERRORS, ValidationError +from django.db.models import Value +from django.db.models.functions import Concat from projects.forms import AssetCreateForm, BusForm, StorageForm from django.template.loader import get_template from django.utils.translation import gettext_lazy as _ + # region sent db nodes to js from django.http import JsonResponse import logging @@ -285,10 +289,15 @@ def handle_storage_unit_form_post( return JsonResponse({"success": False, "exception": ex}, status=422) logger.warning(f"The submitted asset has erroneous field values.") - form_html = get_template("asset/storage_asset_create_form.html") - return JsonResponse( - {"success": False, "form_html": form_html.render({"form": form})}, status=422 + form_html = get_template("asset/storage_asset_create_form.html").render( + { + "form": form, + "templates": get_available_templates( + asset_type_name, scenario.project, request.user + ), + } ) + return JsonResponse({"success": False, "form_html": form_html}, status=422) def handle_asset_form_post(request, scen_id=0, asset_type_name="", asset_uuid=None): @@ -365,10 +374,15 @@ def handle_asset_form_post(request, scen_id=0, asset_type_name="", asset_uuid=No return JsonResponse({"success": True, "asset_id": asset.unique_id}, status=200) logger.warning(f"The submitted asset has erroneous field values.") - form_html = get_template("asset/asset_create_form.html") - return JsonResponse( - {"success": False, "form_html": form_html.render({"form": form})}, status=422 + form_html = get_template("asset/asset_create_form.html").render( + { + "form": form, + "templates": get_available_templates( + asset_type_name, scenario.project, request.user + ), + } ) + return JsonResponse({"success": False, "form_html": form_html}, status=422) def load_scenario_topology_from_db(scen_id): @@ -884,3 +898,25 @@ def create_ESS_objects(all_ess_assets_node_list, scen_id): if asset.name == "capacity": # check if there is a connection link to a bus pass + + +def get_available_templates(asset_type_name, project, user): + # collect available templates + asset_templates = AssetTemplate.objects.filter( + asset_type__asset_type=asset_type_name + ).order_by("created_ts") + # project templates + project_templates = asset_templates.filter( + visibility="project", project=project + ).annotate(display_name=Concat("name", Value(" (prj)"))) + # account templates + account_templates = asset_templates.filter( + visibility="account", created_by=user + ).annotate(display_name=Concat("name", Value(" (acc)"))) + # global templates + global_templates = asset_templates.filter(visibility="global").annotate( + display_name=Concat("name", Value(" (std)")) + ) + return AssetTemplate.objects.none().union( + project_templates, account_templates, global_templates + ) diff --git a/app/projects/views.py b/app/projects/views.py index e5cba7f3..71d1e719 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -21,8 +21,7 @@ from django.template.loader import get_template from jsonview.decorators import json_view -from django.db.models import Q, Value -from django.db.models.functions import Concat +from django.db.models import Q from epa.settings import MVS_GET_URL, MVS_LP_FILE_URL, MVS_SA_GET_URL from .forms import * from .requests import ( @@ -38,6 +37,7 @@ Comment, ConnectionLink, AssetType, + AssetTemplate, UseCase, Scenario, Simulation, @@ -65,6 +65,7 @@ duplicate_scenario_connections, load_scenario_from_dict, load_project_from_dict, + get_available_templates, ) from projects.helpers import format_scenario_for_mvs, PARAMETERS from dashboard.helpers import fetch_user_projects @@ -1619,26 +1620,6 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non ) return render(request, "asset/bus_create_form.html", {"form": form}) - # collect available templates - asset_templates = AssetTemplate.objects.filter( - asset_type__asset_type=asset_type_name - ).order_by("created_ts") - # project templates - project_templates = asset_templates.filter( - visibility="project", project_id=scenario.project_id - ).annotate(display_name=Concat("name", Value(" (prj)"))) - # account templates - account_templates = asset_templates.filter( - visibility="account", created_by=request.user - ).annotate(display_name=Concat("name", Value(" (acc)"))) - # global templates - global_templates = asset_templates.filter(visibility="global").annotate( - display_name=Concat("name", Value(" (std)")) - ) - templates = AssetTemplate.objects.none().union( - project_templates, account_templates, global_templates - ) - if asset_type_name in ["bess", "h2ess", "gess", "hess"]: if asset_uuid: existing_ess_asset = get_object_or_404(Asset, unique_id=asset_uuid) @@ -1695,7 +1676,9 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non "asset/storage_asset_create_form.html", { "form": form, - "templates": templates, + "templates": get_available_templates( + asset_type_name, scenario.project, request.user + ), }, ) else: # all other assets @@ -1731,7 +1714,9 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non context = { "form": form, - "templates": templates, + "templates": get_available_templates( + asset_type_name, scenario.project, request.user + ), "asset_type_name": asset_type_name, "input_timeseries_data": input_timeseries_data, "input_timeseries_timestamps": json.dumps( @@ -1882,9 +1867,9 @@ def template_get_or_create(request, project_id): template = get_object_or_404(AssetTemplate, id=int(request.GET.get("id"))) # check permissions if template.visibility == "project" and project_id != template.project_id: - raise Http404() + raise PermissionDenied if template.visibility == "account" and request.user != template.created_by: - raise Http404() + raise PermissionDenied # visibility = global needs no check return JsonResponse(template.parameters) diff --git a/app/templates/asset/asset_create_form.html b/app/templates/asset/asset_create_form.html index d1b15b3a..58069db7 100644 --- a/app/templates/asset/asset_create_form.html +++ b/app/templates/asset/asset_create_form.html @@ -97,39 +97,7 @@

{% translate "Technical parameters" %}

{% endif %} -
- {% translate "Template" %} -
-
-

{% translate "Use existing" %}

- - -
-
-

{% translate "Create from asset" %}

-
- - -
-
- - -
- - -
-
-
-
-
+ + {% include 'asset/asset_template.html' %} diff --git a/app/templates/asset/asset_template.html b/app/templates/asset/asset_template.html new file mode 100644 index 00000000..8856aa0b --- /dev/null +++ b/app/templates/asset/asset_template.html @@ -0,0 +1,36 @@ +{% load i18n %} + +
+ {% translate "Template" %} +
+
+

{% translate "Use existing" %}

+ + +
+
+

{% translate "Create from asset" %}

+
+ + +
+
+ + +
+ + +
+
+
+
+
diff --git a/app/templates/asset/storage_asset_create_form.html b/app/templates/asset/storage_asset_create_form.html index 704684a2..0d19acae 100644 --- a/app/templates/asset/storage_asset_create_form.html +++ b/app/templates/asset/storage_asset_create_form.html @@ -61,6 +61,8 @@

{% translate "Technical parameters" %}

{% endif %} {% endfor %} + - + + {% include 'asset/asset_template.html' %}