Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/projects/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
70 changes: 70 additions & 0 deletions app/projects/migrations/0026_assettemplate.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
26 changes: 26 additions & 0 deletions app/projects/models/base_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion app/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
name="get_constant_timeseries_id",
),
re_path(
"get/constant/timeseries/id/(?P<ts_length>\d+)/value/(?P<value>\d+(\.\d+)?)/$",
r"get/constant/timeseries/id/(?P<ts_length>\d+)/value/(?P<value>\d+(\.\d+)?)/$",
get_constant_timeseries_id,
name="get_constant_timeseries_id",
),
Expand Down Expand Up @@ -210,6 +210,12 @@
asset_cops_create_or_update,
name="asset_cops_create_or_update",
),
# templates
path(
"project/<int:project_id>/template/",
template_get_or_create,
name="template_get_or_create",
),
# ParameterChangeTracker (track of simulated scenario changes)
path(
"reset_scenario_changes/<int:scen_id>",
Expand Down
67 changes: 64 additions & 3 deletions app/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Comment thread
stefansc1 marked this conversation as resolved.
Outdated
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


Expand Down
78 changes: 77 additions & 1 deletion app/static/js/grid_model_topology.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like here, for example, it seems like it would be cleaner to already have the form data instead of creating a new object here and populating it.

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);
});
});
}
34 changes: 34 additions & 0 deletions app/templates/asset/asset_create_form.html
Copy link
Copy Markdown

@paulapreuss paulapreuss Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you are adding the template fields in the raw html instead of implementing it through a ModelForm from AssetTemplate? That would probably be easier to style given our current formats and there is less maintenance to be done if we end up changing any of the fields.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted the template code to be explicit and readable and did not intend to use a Django form to send template data back when saving. If it were part of AssetCreateForm, the fields would be rendered alongside the other fields, so I would have needed some more custom filters. If it were not (making this its own form), it's even more complicated, since the HTML is replaced and part of another form. And I would have needed to send that other form data for each request.
I found it easier to just build the necessary fields myself and then simply posting data back via fetch. Building new FormData is required because we need to differentiate between asset data and template data (like template name and description). I am reluctant to send both in the same payload and splitting information server-side. Easier to just dump the asset data in the frontend and appending that as a neat little package to our post, so the backend does not need to actually do inspection and can just save to DB. Also, building FormData from scratch is nothing new, the same mechanic is used when saving assets.
Styling should actually be easier because we have direct access to all fields, without Django magic in-between.

Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,39 @@ <h3>{% translate "Technical parameters" %}</h3>

{% endif %}
</div>
<details>
<summary>{% translate "Template" %}</summary>
<div class="row">
<div class="col">
<h4>{% translate "Use existing" %}</h4>
<select id="template-select" style="width:100%">
<option value="">---</option>
{% for template in templates %}
Comment thread
stefansc1 marked this conversation as resolved.
Outdated
<option value="{{ template.id }}" title="{{ template.desc }}">{{ template.display_name }}</option>
{% endfor %}
</select>
<button class="btn btn--medium" onclick="load_template(event)">{% translate "Load from template" %}</button>
</div>
<div class="col">
<h4>{% translate "Create from asset" %}</h4>
<div style="display:grid">
<input name="template-name" placeholder="{% translate 'Name (required)' %}" required>
<textarea name="template-desc" placeholder="{% translate 'Add a description...' %}"></textarea>
</div>
<div>
<button name="template-project" class="btn btn--medium" onclick="save_template(event)">
{% translate "Save for project" %}
</button>
<button name="template-account" class="btn btn--medium" onclick="save_template(event)">
{% translate "Save for account" %}
</button>
<div>
<input type="checkbox" name="template-request_global" id="chk-global">
<label for="chk-global">{% translate "Request to make this standard" %}</label>
</div>
</div>
</div>
</div>
</details>

</div>
1 change: 1 addition & 0 deletions app/templates/scenario/scenario_step2.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ <h3>{{ group_names|get_item:group_name|title }}</h3>
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 %}`;
</script>
<script src="{% static 'js/grid_model_topology.js' %}"></script>

Expand Down