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/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/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..71d1e719 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -37,6 +37,7 @@ Comment, ConnectionLink, AssetType, + AssetTemplate, UseCase, Scenario, Simulation, @@ -64,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 @@ -1618,7 +1620,7 @@ 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"]: + 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 +1671,16 @@ 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": get_available_templates( + asset_type_name, scenario.project, request.user + ), + }, + ) else: # all other assets if asset_uuid: @@ -1703,6 +1714,9 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non context = { "form": form, + "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( @@ -1845,6 +1859,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 PermissionDenied + if template.visibility == "account" and request.user != template.created_by: + raise PermissionDenied + # 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..58069db7 100644 --- a/app/templates/asset/asset_create_form.html +++ b/app/templates/asset/asset_create_form.html @@ -98,4 +98,6 @@

{% translate "Technical parameters" %}

{% endif %} + {% 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' %} 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 %}`;