diff --git a/flexmeasures/ui/crud/assets/forms.py b/flexmeasures/ui/crud/assets/forms.py index b7ed46d18..93fa008ec 100644 --- a/flexmeasures/ui/crud/assets/forms.py +++ b/flexmeasures/ui/crud/assets/forms.py @@ -6,11 +6,7 @@ from flask_security import current_user from flask_wtf import FlaskForm from sqlalchemy import select -from wtforms import ( - StringField, - DecimalField, - SelectField, -) +from wtforms import StringField, DecimalField, SelectField, IntegerField from wtforms.validators import DataRequired, optional from flexmeasures.auth.policy import user_has_admin_access @@ -65,7 +61,8 @@ def to_json(self) -> dict: data["longitude"] = float(data["longitude"]) if data.get("latitude") is not None: data["latitude"] = float(data["latitude"]) - + if data.get("parent_asset_id") is not None: + data["parent_asset_id"] = int(data["parent_asset_id"]) if "csrf_token" in data: del data["csrf_token"] @@ -108,10 +105,14 @@ class NewAssetForm(AssetForm): "Asset type", coerce=int, validators=[DataRequired()] ) account_id = SelectField("Account", coerce=int) + parent_asset_id = IntegerField( + "Parent Asset Id", validators=[optional()] + ) # Add parent_id field def set_account(self) -> tuple[Account | None, str | None]: """Set an account for the to-be-created asset. Return the account (if available) and an error message""" + account_error = None if self.account_id.data == -1: diff --git a/flexmeasures/ui/crud/assets/utils.py b/flexmeasures/ui/crud/assets/utils.py index 495d417fb..5ef258d9a 100644 --- a/flexmeasures/ui/crud/assets/utils.py +++ b/flexmeasures/ui/crud/assets/utils.py @@ -9,6 +9,7 @@ from flexmeasures.auth.policy import check_access from flexmeasures.data import db +from flexmeasures import Asset from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType from flexmeasures.data.models.user import Account from flexmeasures.data.models.time_series import Sensor @@ -18,6 +19,7 @@ is_energy_unit, is_power_unit, ) +from flexmeasures.ui.utils.view_utils import svg_asset_icon_name def get_allowed_price_sensor_data(account_id: Optional[int]) -> Dict[int, str]: @@ -198,3 +200,81 @@ def get_assets_by_account(account_id: int | str | None) -> list[GenericAsset]: process_internal_api_response(ad, make_obj=True) for ad in get_assets_response.json() ] + + +def serialize_asset(asset: Asset, is_head=False) -> dict: + serialized_asset = { + "name": asset.name, + "id": asset.id, + "asset_type": asset.generic_asset_type.name, + "link": url_for("AssetCrudUI:get", id=asset.id), + "icon": svg_asset_icon_name(asset.generic_asset_type.name), + "tooltip": { + "name": asset.name, + "ID": asset.id, + "Asset Type": asset.generic_asset_type.name, + }, + "sensors": [ + {"name": sensor.name, "unit": sensor.unit, "link": url_for("SensorUI:get", id=sensor.id)} for sensor in asset.sensors + ], + } + + if asset.parent_asset and not is_head: + serialized_asset["parent"] = asset.parent_asset.id + + return serialized_asset + + +def get_list_assets_chart( + asset: Asset, + base_asset: Asset, + parent_depth=0, + child_depth=0, + look_for_child=False, + is_head=False, +) -> list[dict]: + """ + Recursively builds a tree of assets from a given asset and its parents and children up to a certain depth. + + Args: + asset: The asset to start the recursion from + base_asset: The asset that is the base of the chart + parent_depth: The current depth of the parents hierarchy + child_depth: The current depth of the children hierarchy + look_for_child: If True, start looking for children of the current asset + is_head: If True, the current asset is the head of the chart + + Returns: + A list of dictionaries representing the assets in the tree + """ + assets = list() + asset_def = serialize_asset(asset, is_head=is_head) + + # Fetch parents if there is parent asset and parent_depth is less than 2 + if asset.parent_asset and parent_depth < 2 and not look_for_child: + parent_depth += 1 + assets += get_list_assets_chart( + asset=asset.parent_asset, + base_asset=base_asset, + parent_depth=parent_depth, + is_head=False if parent_depth < 2 else True, + ) + else: + look_for_child = True + parent_depth = ( + 2 # Auto increase depth in the parents hierarchy is less than two + ) + + assets.append(asset_def) + + if look_for_child and child_depth < 2: + child_depth += 1 + for child in base_asset.child_assets: + assets += get_list_assets_chart( + child, + parent_depth=parent_depth, + child_depth=child_depth, + base_asset=child, + ) + + return assets diff --git a/flexmeasures/ui/crud/assets/views.py b/flexmeasures/ui/crud/assets/views.py index 5a69e0d01..a65a8e9aa 100644 --- a/flexmeasures/ui/crud/assets/views.py +++ b/flexmeasures/ui/crud/assets/views.py @@ -6,6 +6,8 @@ from webargs.flaskparser import use_kwargs from werkzeug.exceptions import NotFound +from flexmeasures.ui.utils.view_utils import svg_asset_icon_name + from flexmeasures.data import db from flexmeasures.auth.error_handling import unauthorized_handler from flexmeasures.auth.policy import check_access @@ -15,7 +17,7 @@ GenericAsset, get_center_location_of_assets, ) -from flexmeasures.ui.utils.view_utils import ICON_MAPPING +from flexmeasures.ui.utils.view_utils import ICON_MAPPING, available_units from flexmeasures.data.models.user import Account from flexmeasures.ui.utils.view_utils import render_flexmeasures_template from flexmeasures.ui.crud.api_wrapper import InternalApi @@ -25,13 +27,12 @@ user_can_create_assets, user_can_delete, user_can_update, + get_list_assets_chart, ) from flexmeasures.data.services.sensors import ( build_sensor_status_data, build_asset_jobs_data, ) -from flexmeasures.ui.utils.view_utils import available_units - """ Asset crud view. @@ -94,6 +95,45 @@ def owned_by(self, account_id: str): user_can_create_assets=user_can_create_assets(), ) + @login_required + @route("/<id>/context") + def context(self, id: str): + """/assets/<id>/context""" + asset = db.session.query(GenericAsset).filter_by(id=id).first() + if asset is None: + assets = [] + else: + assets = get_list_assets_chart(asset, base_asset=asset) + + current_asset_sensors = [ + { + "name": sensor.name, + "unit": sensor.unit, + "link": url_for("SensorUI:get", id=sensor.id), + } + for sensor in asset.sensors + ] + # Add Extra node to the current asset + add_child_asset = { + "name": "Add Child Asset", + "id": "new", + "asset_type": asset.generic_asset_type.name, + "link": url_for("AssetCrudUI:post", id="new", parent_asset_id=asset.id), + "icon": svg_asset_icon_name("add_asset"), + "tooltip": "", + "sensors": [], + "parent": asset.id, + } + + assets.append(add_child_asset) + + return render_flexmeasures_template( + "crud/asset_context.html", + assets=assets, + asset=asset, + current_asset_sensors=current_asset_sensors, + ) + @use_kwargs(StartEndTimeSchema, location="query") @login_required def get(self, id: str, **kwargs): @@ -102,18 +142,32 @@ def get(self, id: str, **kwargs): - start_time: minimum time of the events to be shown - end_time: maximum time of the events to be shown """ + parent_asset_id = request.args.get("parent_asset_id", "") + parent_asset_name = "" + account_id = None if id == "new": if not user_can_create_assets(): return unauthorized_handler(None, []) asset_form = NewAssetForm() asset_form.with_options() + if parent_asset_id: + parent_asset = db.session.get(GenericAsset, parent_asset_id) + if parent_asset: + asset_form.account_id.data = str( + parent_asset.account_id + ) # Pre-set account + parent_asset_name = parent_asset.name + account_id = parent_asset.account_id return render_flexmeasures_template( "crud/asset_new.html", asset_form=asset_form, msg="", map_center=get_center_location_of_assets(user=current_user), mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""), + parent_asset_name=parent_asset_name, + parent_asset_id=parent_asset_id, + account_id=account_id, ) get_asset_response = InternalApi().get(url_for("AssetAPI:fetch_one", id=id)) diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css index e77b3dbfd..2bbd9ee93 100644 --- a/flexmeasures/ui/static/css/flexmeasures.css +++ b/flexmeasures/ui/static/css/flexmeasures.css @@ -1970,3 +1970,12 @@ body.touched [title]:hover:after { color: #000; } /* FlexContext Modal Dialogue - End*/ + + +/* Custom CSS for SVG */ +.mark-image image { + transform: translate(1px, 10px) !important; + width: 34px !important; + height: 20px !important; + vertical-align: middle; +} \ No newline at end of file diff --git a/flexmeasures/ui/templates/_macros.html b/flexmeasures/ui/templates/_macros.html new file mode 100644 index 000000000..f54e9c16b --- /dev/null +++ b/flexmeasures/ui/templates/_macros.html @@ -0,0 +1,183 @@ +{% macro show_tree(assets, current_asset_name) %} + <div class="container"> + <div id="view"></div> + </div> + + +<script type="text/javascript"> + window.onload = function(){ + + let assets = {{ assets | safe }}; + let currentAssetName = '{{ current_asset_name | safe}}'; + + {# Vega Spec adapted from https://stackoverflow.com/a/76300309 #} + + +treeSpecs = { + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 1000, + "height": 650, + "padding": 0, + "autosize": {"type": "fit", "resize": false}, + "signals": [ + {"name": "nodeWidth", "value": 190}, + {"name": "nodeHeight", "value": 40}, + {"name": "verticalNodeGap", "value": 10}, + {"name": "horizontalNodeGap", "value": 60}, + {"name": "currentAssetName", "value": currentAssetName}, + { + "name": "scaledNodeHeight", + "update": "abs(nodeHeight/ max(span(ydom),height))*height" + }, + {"name": "scaledNodeWidth", "update": "(nodeWidth/ span(xdom))*width"}, + {"name": "xrange", "update": "[0, width]"}, + {"name": "yrange", "update": "[0, height]"}, + {"name": "scaledFont13", "update": "(30/ span(xdom))*width"}, + {"name": "scaledLimit", "update": "(30/ span(xdom))*width"}, + { + "name": "xdom", + "update": "slice(xext)", + }, + { + "name": "ydom", + "update": "slice(yext)", + }, + ], + "data": [ + { + "name": "tree", + "values": assets, + "transform": [ + {"type": "stratify", "key": "id", "parentKey": "parent"}, + { + "type": "tree", + "method": "tidy", + "size": [{"signal": "nodeHeight *0.1"}, + {"signal": "width"} + ], + "separation": {"signal": "true"}, + "nodeSize" : [ + {"signal": "nodeHeight+verticalNodeGap"}, + {"signal": "nodeWidth+horizontalNodeGap"} + + ], + "as": ["y", "x", "depth", "children"], + }, + {"type": "extent", "field": "x", "signal": "xext"}, + {"type": "extent", "field": "y", "signal": "yext"} + ] + }, + { + "name": "links", + "source": "tree", + "transform": [ + {"type": "treelinks", "signal": "upstream"}, + { + "type": "linkpath", + "orient": "horizontal", + "shape": {"signal": "'diagonal'"}, + "sourceY": {"expr": "scale('yscale', datum.source.y)"}, + "sourceX": {"expr": "scale('xscale', datum.source.x)"}, + "targetY": {"expr": "scale('yscale', datum.target.y)"}, + "targetX": {"expr": "scale('xscale', datum.target.x)"} + } + ] + } + ], + "scales": [ + { + "name": "xscale", + "zero": false, + "domain": {"signal": "xdom"}, + "range": {"signal": "xrange"} + }, + { + "name": "yscale", + "zero": false, + "domain": {"signal": "ydom"}, + "range": {"signal": "yrange"} + } + ], + "marks": [ + { + "type": "path", + "from": {"data": "links"}, + "encode": { + "update": {"path": {"field": "path"}, "stroke": {"value": "#aaa"}} + } + }, + { + "name": "node", + "description": "The Parent Node", + "type": "group", + "clip": false, + "from": {"data": "tree"}, + "encode": { + "update": { + "x": {"field": "x", "scale": "xscale"}, + "width": { + "signal": "datum.name === currentAssetName ? scaledNodeWidth * 1.1 : scaledNodeWidth" + }, + "yc": {"field": "y", "scale": "yscale"}, + "height": { + "signal": "datum.name === currentAssetName ? scaledNodeHeight * 1.1 : scaledNodeHeight" + }, + "fill": { + "signal": "datum.name === currentAssetName ? 'gold' : datum.name === 'Add Child Asset' ? 'green' : 'lightblue'" + }, + "stroke": { + "signal": "datum.name === currentAssetName ? 'darkorange' : 'black'" + }, + "strokeWidth": { + "signal": "datum.name === currentAssetName ? 4 : 1" + }, + "cornerRadius": {"value": 5}, + "href": {"signal": "datum.link"}, + "tooltip": {"signal": "datum.tooltip"} + } + }, + "marks": [ + { + "type": "text", + "interactive": false, + "name": "name", + "encode": { + "update": { + "x": {"signal": "(5/ span(xdom))*width + (scaledNodeWidth * 0.15)"}, + "y": {"signal": "(5/ span(xdom))*width + (scaledNodeHeight * 0.15)"}, + "fontWeight": {"value": "bold"}, + "baseline": {"value": "top"}, + "text": {"signal": "parent.name"}, + "fontSize": { + "signal": "datum.name === currentAssetName ? scaledFont13 * 1.5 : scaledFont13" + }, + "fill": { + "signal": "parent.name === currentAssetName ? 'black' : parent.name === 'Add Child Asset' ? 'white' : 'darkblue'" + }, + "limit": {"signal": "scaledNodeWidth-scaledLimit"}, + "font": {"value": "Calibri"}, + "href": {"signal": "datum.link"}, + "tooltip": {"signal": "parent.tooltip"} + } + } + }, + { + "type": "image", + "encode": { + "update": { + "x": {"signal": "-(scaledNodeWidth * 0.1)"}, + "y": {"signal": "-(scaledNodeHeight * 0.1)"}, + "width": {"signal": "scaledNodeWidth * 0.2"}, + "height": {"signal": "scaledNodeHeight * 0.2"}, + "url": {"signal": "parent.icon"} + } + } + }, + ] + } + ] +} +vegaEmbed("#view", treeSpecs, { renderer: "svg" }) +} + </script> +{% endmacro %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/crud/asset.html b/flexmeasures/ui/templates/crud/asset.html index 071b70a3a..4fd63e41d 100644 --- a/flexmeasures/ui/templates/crud/asset.html +++ b/flexmeasures/ui/templates/crud/asset.html @@ -20,8 +20,10 @@ <div class="header-action-button"> {% if user_can_create_assets %} <div> - <button class="btn btn-sm btn-responsive btn-success create-button mb-3" type="submit"> - <a href="/assets/new">Create new asset</a> + <button class="btn btn-sm btn-responsive btn-success mb-3" type="button" style=" + margin-top: 6px;"> + <a class="text-decoration-none text-white create-button" href="/assets/new?parent_asset_id={{asset.id}}"> Create new asset</a> + </button> </div> {% endif %} diff --git a/flexmeasures/ui/templates/crud/asset_context.html b/flexmeasures/ui/templates/crud/asset_context.html new file mode 100644 index 000000000..a08bee847 --- /dev/null +++ b/flexmeasures/ui/templates/crud/asset_context.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} + +{% set active_page = "assets" %} + +{% block title %} {{ asset.name }} - Scenario {% endblock %} + +{% block divs %} + +{% block breadcrumbs %} {{ super() }} {% endblock %} + + <div class="container-fluid"> + <div class="row"> + <div class="col-sm-2"></div> + <div class="col-sm-8 card"> + <div class="row"> + <div class="col-sm-10"> + <div class="d-flex justify-content-between align-items-center" style="margin-bottom: 10px;"> + <h1> + Asset: <a href="{{ url_for('AssetCrudUI:get', id=asset.id) }}"> + <i>{{ asset.name }}</i> + </a> + <span>({{ asset.generic_asset_type.name.split('.')[-1] | title }})</span> + </h1> + <div class="d-flex"> + <button class="btn btn-sm btn-primary me-2" onclick='openSensorsModal()'> + Show sensors + </button> + <button class="btn btn-sm btn-primary" id="viewMapButton"> + View Map + </button> + </div> + </div> + </div> + <div class="col-sm-2"> + {% if can_delete %} + <button type="button" class="btn delete-button" onClick="delete_asset(event, {{ asset.id }}, '{{ asset.name }}')">Delete Scenario</button> + {% endif %} + </div> + <div class="col-sm-12"> + {% from "_macros.html" import show_tree %} + {{ show_tree(assets, asset.name) }} + </div> + </div> + </div> + <div class="col-sm-2"></div> + + <!-- Bootstrap Modal for Sensors --> + <div class="modal fade" id="sensorsModal" tabindex="-1" aria-labelledby="modalTitle" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="modalTitle">Node Details</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <table id="modalTable" class="table table-bordered"> + <thead id="modalTableHead"></thead> + <tbody id="modalTableBody"></tbody> + </table> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + </div> + </div> + </div> + </div> + + <!-- Bootstrap Modal for Map --> + <div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalTitle" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="mapModalTitle">{{asset.name}} Map View</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <div class="row mx-1"> + <div class="alert alert-info d-none" id="tzwarn"></div> + <div class="alert alert-info d-none" id="dstwarn"></div> + </div> + <div id="modalMap" style="height: 400px;"></div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + </div> + </div> + </div> + </div> + + </div> + </div> + + <script> + function openSensorsModal() { + const sensors = {{ current_asset_sensors | safe }}; + document.getElementById("modalTitle").innerText = "Sensors for " + '{{ asset.name }}'; + let tableHead = document.getElementById("modalTableHead"); + let tableBody = document.getElementById("modalTableBody"); + + // Clear previous content + tableHead.innerHTML = ""; + tableBody.innerHTML = ""; + + if (sensors && sensors.length > 0) { + let keys = Object.keys(sensors[0]); // e.g., ['name', 'unit'] + keys.pop("link"); // Remove the 'link' from the table + + // Create table header row + let headerRow = document.createElement("tr"); + keys.forEach(key => { + let headerText = key.charAt(0).toUpperCase() + key.slice(1); + let th = document.createElement("th"); // Use <th> for header cells + th.innerText = headerText; + th.setAttribute("aria-label", headerText); // set aria-label so floatThead can pick it up + headerRow.appendChild(th); + }); + tableHead.appendChild(headerRow); + + // Force destroy the floatThead instance + setTimeout(function(){ + if ($.fn.floatThead) { + $('#modalTable').floatThead('destroy'); + } + }, 200); + + // Populate table with sensor data + sensors.forEach(sensor => { + let row = document.createElement("tr"); + keys.forEach(key => { + let td = document.createElement("td"); + if (key === "name") { + // Create an anchor element + let a = document.createElement("a"); + a.innerText = sensor[key] !== undefined ? sensor[key] : "N/A"; + // Set the href; you can modify this as needed, for example using sensor.link if available + a.href = sensor.link; + td.appendChild(a); + } else { + td.innerText = sensor[key] !== undefined ? sensor[key] : "N/A"; + } + row.appendChild(td); + }); + tableBody.appendChild(row); + }); + } else { + tableBody.innerHTML = "<tr><td colspan='100%'>No sensor data available</td></tr>"; + } + + // Show Bootstrap modal + let modal = new bootstrap.Modal(document.getElementById('sensorsModal')); + modal.show(); + } + + document.getElementById('viewMapButton').addEventListener('click', function() { + let modal = new bootstrap.Modal(document.getElementById('mapModal')); + modal.show(); + + // Initialize the map inside the modal + setTimeout(function() { + var modalMap = L.map('modalMap', { center: [{{ asset.latitude | replace("None", 10) }}, {{ asset.longitude | replace("None", 10) }}], zoom: 10 }); + addTileLayer(modalMap, '{{ mapboxAccessToken }}'); + + var asset_icon = new L.DivIcon({ + className: 'map-icon', + html: '<i class="icon-empty-marker center-icon supersize"></i><i class="overlay center-icon {{ asset.generic_asset_type.name | default("info") | asset_icon }}"></i>', + iconSize: [100, 100], // size of the icon + iconAnchor: [50, 50], // point of the icon which will correspond to marker's location + popupAnchor: [0, -50] // point from which the popup should open relative to the iconAnchor + }); + + var marker = L.marker([{{ asset.latitude | replace("None", 10) }}, {{ asset.longitude | replace("None", 10) }}], { icon: asset_icon }).addTo(modalMap); + }, 500); // Delay to ensure modal is fully shown before initializing the map + }); + </script> + + <style> + details { + display: none; + } + </style> + +<!-- Initialise the map --> +<script src="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js"></script> +<script src="{{ url_for('flexmeasures_ui.static', filename='js/map-init.js') }}"></script> + +<script type="text/javascript"> + + // create map + var assetMap = L + .map('mapid', { center: [{{ asset.latitude | replace("None", 10) }}, {{ asset.longitude | replace("None", 10) }}], zoom: 10}) + .on('popupopen', function () { + $(function () { + $('[data-toggle="tooltip"]').tooltip(); + }); + }); + addTileLayer(assetMap, '{{ mapboxAccessToken }}'); + + // create marker + var asset_icon = new L.DivIcon({ + className: 'map-icon', + html: '<i class="icon-empty-marker center-icon supersize"></i><i class="overlay center-icon {{ asset.generic_asset_type.name | default("info") | asset_icon }}"></i>', + iconSize: [100, 100], // size of the icon + iconAnchor: [50, 50], // point of the icon which will correspond to marker's location + popupAnchor: [0, -50] // point from which the popup should open relative to the iconAnchor + }); + var marker = L + .marker( + [{{ asset.latitude | replace("None", 10) }}, {{ asset.longitude | replace("None", 10) }}], + { icon: asset_icon } + ).addTo(assetMap); + + assetMap.on('click', function (e) { + $("#latitude").val(e.latlng.lat.toFixed(4)); + $("#longitude").val(e.latlng.lng.toFixed(4)); + marker.setLatLng(e.latlng); + }); +</script> +{% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/crud/asset_new.html b/flexmeasures/ui/templates/crud/asset_new.html index 9ad399f8f..27f9bd4c2 100644 --- a/flexmeasures/ui/templates/crud/asset_new.html +++ b/flexmeasures/ui/templates/crud/asset_new.html @@ -23,8 +23,8 @@ <div class="row"> <div class="col-md-6"> - <h2> Creating a new asset </h2> - + <h2>Creating a new asset {% if parent_asset_name %} for {{ parent_asset_name }} {% endif %}</h2> + <input type="hidden" name="parent_asset_id" value="{{ parent_asset_id }}"> <div class="form-group"> {{ asset_form.name.label(class="col-md-6 control-label") }} <div class="col-md-6"> @@ -47,8 +47,17 @@ <h2> Creating a new asset </h2> {{ asset_form.account_id.label(class="col-md-6 control-label") }} <div class="col-md-6"> {{ asset_form.account_id(class_="form-control") }} + {% if account_id %} + <script> + document.addEventListener("DOMContentLoaded", function() { + var accountSelect = document.querySelector("select[name='account_id']"); + accountSelect.value = {{ account_id }}; // Set the account_id value without quotes + accountSelect.disabled = true; // Disable the select field + }); + </script> + {% endif %} {% for error in asset_form.errors.account_id %} - <span style="color: red;">[{{error}}]</span> + <span style="color: red;">[{{ error }}]</span> {% endfor %} </div> </div> diff --git a/flexmeasures/ui/templates/crud/assets.html b/flexmeasures/ui/templates/crud/assets.html index 0ad5083b6..fcb082432 100644 --- a/flexmeasures/ui/templates/crud/assets.html +++ b/flexmeasures/ui/templates/crud/assets.html @@ -21,7 +21,7 @@ this.id = id; this.location = ""; - this.url = `/assets/${id}` + this.url = `/assets/${id}/context` this.status = ` <a href="/assets/${id}/status"> <button type="button" class="btn btn-primary">Status</button> diff --git a/flexmeasures/ui/utils/view_utils.py b/flexmeasures/ui/utils/view_utils.py index 6ed703ac9..940c2d222 100644 --- a/flexmeasures/ui/utils/view_utils.py +++ b/flexmeasures/ui/utils/view_utils.py @@ -209,6 +209,20 @@ def _minimal_ext_cmd(cmd: list): "wind speed": "wi wi-strong-wind", } +SVG_ICON_MAPPING = { + # site structure + "building": "https://api.iconify.design/mdi/home-city.svg", + "battery": "https://api.iconify.design/mdi/battery.svg", + "simulation": "https://api.iconify.design/mdi/home-city.svg", + "site": "https://api.iconify.design/mdi/map-marker-outline.svg", + "scenario": "https://api.iconify.design/mdi/binoculars.svg", + "pv": "https://api.iconify.design/wi/day-sunny.svg", + "solar": "https://api.iconify.design/wi/day-sunny.svg", + "chargepoint": "https://api.iconify.design/material-symbols/ev-station-outline.svg", + "ev": "https://api.iconify.design/material-symbols/ev-station-outline.svg", + "add_asset": "https://api.iconify.design/material-symbols/add-rounded.svg?color=white", # Plus Icon for Add Asset +} + def asset_icon_name(asset_type_name: str) -> str: """Icon name for this asset type. @@ -226,6 +240,15 @@ def asset_icon_name(asset_type_name: str) -> str: return ICON_MAPPING.get(asset_type_name, f"icon-{asset_type_name}") +def svg_asset_icon_name(asset_type_name: str) -> str: + + if asset_type_name: + asset_type_name = asset_type_name.split(".")[-1].lower() + return SVG_ICON_MAPPING.get( + asset_type_name, "https://api.iconify.design/fa-solid/question-circle.svg" + ) + + def username(user_id) -> str: user = db.session.get(User, user_id) if user is None: