From 2a129a14ba15bd8637acc6d37894f24535e4d807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Fri, 3 Jul 2026 00:47:02 +0200 Subject: [PATCH 1/5] give generic asset table a description column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- .../v3_0/tests/test_assets_api_fresh_db.py | 5 +++- flexmeasures/api/v3_0/tests/utils.py | 1 + ...9c1a6d_add_description_to_generic_asset.py | 27 +++++++++++++++++++ flexmeasures/data/models/generic_assets.py | 1 + flexmeasures/data/schemas/generic_assets.py | 1 + flexmeasures/ui/static/openapi-specs.json | 6 +++++ .../ui/templates/assets/asset_context.html | 7 ++++- .../ui/templates/assets/asset_new.html | 12 ++++++++- .../ui/templates/assets/asset_properties.html | 10 +++++++ flexmeasures/ui/views/assets/forms.py | 7 ++++- flexmeasures/ui/views/assets/views.py | 1 + 11 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py diff --git a/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py index f0da27295b..8dee2c6c8c 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py @@ -35,12 +35,14 @@ def test_post_an_asset_as_admin(client, setup_api_fresh_test_data, requesting_us print("Server responded with:\n%s" % post_assets_response.json) assert post_assets_response.status_code == 201 assert post_assets_response.json["latitude"] == 30.1 + assert post_assets_response.json["description"] == post_data["description"] asset: GenericAsset = db.session.execute( select(GenericAsset).filter_by(name=post_data["name"]) ).scalar_one_or_none() assert asset is not None assert asset.latitude == 30.1 + assert asset.description == post_data["description"] @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) @@ -106,7 +108,7 @@ def test_edit_an_asset(client, setup_api_fresh_test_data, requesting_user, db): with AccountContext("Test Supplier Account") as supplier: existing_asset = supplier.generic_assets[0] - post_data = dict(latitude=10) + post_data = dict(latitude=10, description="Updated description") edit_asset_response = client.patch( url_for("AssetAPI:patch", id=existing_asset.id), json=post_data, @@ -118,6 +120,7 @@ def test_edit_an_asset(client, setup_api_fresh_test_data, requesting_user, db): assert updated_asset.latitude == 10 # changed value assert updated_asset.longitude == existing_asset.longitude assert updated_asset.name == existing_asset.name + assert updated_asset.description == "Updated description" @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) diff --git a/flexmeasures/api/v3_0/tests/utils.py b/flexmeasures/api/v3_0/tests/utils.py index 79683e868c..2a293c9eb3 100644 --- a/flexmeasures/api/v3_0/tests/utils.py +++ b/flexmeasures/api/v3_0/tests/utils.py @@ -42,6 +42,7 @@ def make_sensor_data_request_for_gas_sensor( def get_asset_post_data(account_id: int = 1, asset_type_id: int = 1) -> dict: post_data = { "name": "Test battery 2", + "description": "A test battery asset.", "latitude": 30.1, "longitude": 100.42, "generic_asset_type_id": asset_type_id, diff --git a/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py b/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py new file mode 100644 index 0000000000..fa578de45e --- /dev/null +++ b/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py @@ -0,0 +1,27 @@ +"""add description to generic asset + +Revision ID: 4b0f2e9c1a6d +Revises: 55d8936a55f9 +Create Date: 2026-07-02 17:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "4b0f2e9c1a6d" +down_revision = "55d8936a55f9" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("generic_asset", schema=None) as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("generic_asset", schema=None) as batch_op: + batch_op.drop_column("description") diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index ad64be981d..763fc679d5 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -241,6 +241,7 @@ class GenericAsset(db.Model, AuthModelMixin): # No relationship id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), default="") + description = db.Column(db.Text, nullable=True) latitude = db.Column(db.Float, nullable=True) longitude = db.Column(db.Float, nullable=True) attributes = db.Column(MutableDict.as_mutable(JSONB), nullable=False, default={}) diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index f09ad37d48..6217d4cb3a 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -349,6 +349,7 @@ class GenericAssetSchema(ma.SQLAlchemySchema): id = ma.auto_field(dump_only=True) name = fields.Str(required=True) + description = fields.Str(required=False, allow_none=True) account_id = ma.auto_field() owner = ma.Nested("AccountSchema", dump_only=True, only=("id", "name")) latitude = LatitudeField(allow_none=True) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 599e06d878..2e911d0e31 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5018,6 +5018,12 @@ "name": { "type": "string" }, + "description": { + "type": [ + "string", + "null" + ] + }, "account_id": { "type": [ "integer", diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index 5c84a5a607..e424ebcc42 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -28,7 +28,12 @@

-

Type: {{ asset.generic_asset_type.name.split('.')[-1] | title }}

+

+ Type: {{ asset.generic_asset_type.name.split('.')[-1] | title }} + {% if asset.description %} + Description: {{ asset.description }} + {% endif %} +

diff --git a/flexmeasures/ui/templates/assets/asset_new.html b/flexmeasures/ui/templates/assets/asset_new.html index 74c63beb68..ee231cf916 100644 --- a/flexmeasures/ui/templates/assets/asset_new.html +++ b/flexmeasures/ui/templates/assets/asset_new.html @@ -54,6 +54,16 @@

Creating a new asset {% if parent_asset_name %} under asset + {{ asset_form.description.label(class="col-md-6 control-label") }} +
+ {{ asset_form.description.description }} + {{ asset_form.description(class_="form-control", rows="3") }} + {% for error in asset_form.errors.description %} + [{{error}}] + {% endfor %} +
+

{% if asset_form.account_id %}
{{ asset_form.account_id.label(class="col-md-6 control-label") }} @@ -537,4 +547,4 @@

Location

}()); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/flexmeasures/ui/templates/assets/asset_properties.html b/flexmeasures/ui/templates/assets/asset_properties.html index 4564e7ef45..b29dc02847 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -38,6 +38,16 @@

Edit {{ asset.name }}

{% endfor %}
+
+ {{ asset_form.description.label(class="control-label") }} +
+ {{ asset_form.description.description }} + {{ asset_form.description(class_="form-control", rows="3") }} + {% for error in asset_form.errors.description %} + [{{error}}] + {% endfor %} +
+
{{ asset_form.latitude.label(class="control-label") }}
diff --git a/flexmeasures/ui/views/assets/forms.py b/flexmeasures/ui/views/assets/forms.py index e1570e9b7a..eeba27cb7d 100644 --- a/flexmeasures/ui/views/assets/forms.py +++ b/flexmeasures/ui/views/assets/forms.py @@ -7,7 +7,7 @@ from flask_security import current_user from flask_wtf import FlaskForm from sqlalchemy import select -from wtforms import StringField, DecimalField, SelectField, IntegerField +from wtforms import StringField, DecimalField, SelectField, IntegerField, TextAreaField from wtforms.validators import DataRequired, optional from marshmallow import ValidationError @@ -25,6 +25,11 @@ class AssetForm(FlaskForm): "Name", validators=[DataRequired()], ) + description = TextAreaField( + "Description", + validators=[optional()], + description="Optional description to help users understand what this asset represents.", + ) latitude = DecimalField( "Latitude", validators=[optional()], diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index f580a064e7..30084e7ad9 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -409,6 +409,7 @@ def properties(self, id: str): asset_summary = { "Name": asset.name, "Type": asset.generic_asset_type.name, + "Description": asset.description or "", "Latitude": asset.latitude, "Longitude": asset.longitude, "Parent Asset": ( From 9667e1c79013f731b02be993a91b07a7cf66d6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Fri, 3 Jul 2026 01:52:26 +0200 Subject: [PATCH 2/5] add/update three basic asset templates on startup (can be turned off in settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- documentation/configuration.rst | 15 ++ flexmeasures/api/common/utils/api_utils.py | 29 +++ .../api/v3_0/tests/test_assets_api.py | 44 ++++ flexmeasures/app.py | 19 ++ flexmeasures/data/scripts/data_gen.py | 220 +++++++++++++++++- .../data/tests/test_template_assets.py | 71 ++++++ flexmeasures/utils/config_defaults.py | 2 + 7 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 flexmeasures/data/tests/test_template_assets.py diff --git a/documentation/configuration.rst b/documentation/configuration.rst index 1c79235e6a..ed878707ae 100644 --- a/documentation/configuration.rst +++ b/documentation/configuration.rst @@ -781,6 +781,21 @@ When ``FLEXMEASURES_MODE=demo``\ , this can hold login credentials (demo user em Default: ``None`` +FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Whether FlexMeasures should create its built-in starter template assets when the +application starts. + +If ``True``, FlexMeasures provisions a small set of public starter templates, +such as ``Battery Template``, ``EV Charger Template`` and ``Heat Pump Template``, +if they do not exist yet. Together with the asset copy workflow, new users will +find FlexMeasures to be more useful out of the box. + +If ``False``, no template assets are created automatically at startup. + +Default: ``True`` + .. _sunset-config: Sunset diff --git a/flexmeasures/api/common/utils/api_utils.py b/flexmeasures/api/common/utils/api_utils.py index 2d7e43b48e..600942021d 100644 --- a/flexmeasures/api/common/utils/api_utils.py +++ b/flexmeasures/api/common/utils/api_utils.py @@ -260,6 +260,34 @@ def convert_asset_json_fields(asset_kwargs): return asset_kwargs +def _remove_template_copy_guidance(description: str | None) -> str | None: + """Strip template-specific copy instructions from an asset description.""" + if not description: + return description + + cleaned_description = re.sub( + r"\s*Copy this(?: asset)?\b.*?(?:\.\s*|$)", + "", + description, + flags=re.IGNORECASE, + ).strip() + return cleaned_description or None + + +def _sanitize_copied_asset_kwargs(asset_kwargs: dict) -> dict: + """Turn a copied template into a regular asset payload.""" + attributes = asset_kwargs.get("attributes") + if isinstance(attributes, dict) and "template" in attributes: + sanitized_attributes: dict = deepcopy(attributes) + sanitized_attributes.pop("template", None) + asset_kwargs["attributes"] = sanitized_attributes + asset_kwargs["description"] = _remove_template_copy_guidance( + asset_kwargs.get("description") + ) + + return asset_kwargs + + def _copy_direct_sensors( source_asset: GenericAsset, copied_asset: GenericAsset ) -> dict[int, int]: @@ -465,6 +493,7 @@ def _copy_asset_subtree( # set external_id to None to avoid conflicts with unique constraint on (account_id, external_id) asset_kwargs["external_id"] = None asset_kwargs = convert_asset_json_fields(asset_kwargs) + asset_kwargs = _sanitize_copied_asset_kwargs(asset_kwargs) copied_asset = GenericAsset(**asset_kwargs) db.session.add(copied_asset) diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 85485d2059..fb5744e70a 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -907,6 +907,50 @@ def test_copy_asset_increments_name_under_same_parent( assert third_copy.name == f"{battery.name} (Copy 3)" +def test_copy_template_asset_drops_template_metadata( + setup_api_test_data, setup_accounts, db +): + """Copying a template should yield a regular asset in the target account.""" + prosumer_account = setup_accounts["Prosumer"] + battery = db.session.scalars( + select(GenericAsset).filter_by( + account_id=prosumer_account.id, + name="Test grid connected battery storage", + ) + ).first() + assert battery is not None + + template_asset = GenericAsset( + name="Battery Template", + generic_asset_type_id=battery.generic_asset_type_id, + account_id=None, + description=( + "Single battery asset with example power and state-of-charge sensors, " + "plus a basic storage flex-model. Copy this to start modeling a battery." + ), + attributes={"template": {"key": "battery-template", "has_scenarios": False}}, + ) + db.session.add(template_asset) + db.session.flush() + + assert template_asset.attributes["template"]["key"] == "battery-template" + assert "Copy this" in template_asset.description + + copied_asset = copy_asset(template_asset, account=prosumer_account) + + assert copied_asset.account_id == prosumer_account.id + assert copied_asset.parent_asset_id is None + assert copied_asset.attributes == {} + assert copied_asset.description == ( + "Single battery asset with example power and state-of-charge sensors, " + "plus a basic storage flex-model." + ) + + # The source template stays available as a template for future copies. + assert template_asset.attributes["template"]["key"] == "battery-template" + assert "Copy this" in template_asset.description + + def test_copy_asset_to_another_account_preserves_config( setup_api_test_data, setup_accounts, setup_markets, setup_generic_asset_types, db ): diff --git a/flexmeasures/app.py b/flexmeasures/app.py index ee9fcab76a..252f81c060 100644 --- a/flexmeasures/app.py +++ b/flexmeasures/app.py @@ -50,6 +50,7 @@ def create( # noqa C901 ) from flexmeasures.utils.error_utils import add_basic_error_handlers from flexmeasures.utils.secrets_utils import set_secret_key, set_totp_secrets + from sqlalchemy.exc import OperationalError, ProgrammingError configure_logging() # do this first, see https://flask.palletsprojects.com/en/2.0.x/logging cfg_location = find_flexmeasures_cfg() # Find flexmeasures.cfg location @@ -209,6 +210,24 @@ def create( # noqa C901 register_ui_at(app) + if ( + app.config.get("FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP", False) + and not app.testing + and app.config.get("FLEXMEASURES_ENV") != "documentation" + ): + from flexmeasures.data import db + from flexmeasures.data.scripts.data_gen import ( + provision_default_template_assets, + ) + + try: + with app.app_context(): + provision_default_template_assets(db) + except (OperationalError, ProgrammingError) as exc: + app.logger.warning( + f"Skipping startup template provisioning due to an error: {exc}" + ) + # Global template variables for both our own templates and external templates @app.context_processor def set_global_template_variables(): diff --git a/flexmeasures/data/scripts/data_gen.py b/flexmeasures/data/scripts/data_gen.py index f2c1a7bde1..0870837f23 100644 --- a/flexmeasures/data/scripts/data_gen.py +++ b/flexmeasures/data/scripts/data_gen.py @@ -19,10 +19,6 @@ from flexmeasures.cli.utils import MsgStyle -BACKUP_PATH = app.config.get("FLEXMEASURES_DB_BACKUP_PATH") -LOCAL_TIME_ZONE = app.config.get("FLEXMEASURES_TIMEZONE") - - def add_default_data_sources(db: SQLAlchemy): for source_name, source_type in ( ("Seita", "demo script"), @@ -146,6 +142,222 @@ def add_transmission_zone_asset(country_code: str, db: SQLAlchemy) -> GenericAss return transmission_zone +def _ensure_public_root_asset( + db: SQLAlchemy, + *, + name: str, + asset_type: GenericAssetType, + description: str, + flex_model: dict | None = None, + attributes: dict | None = None, + sensors_to_show: list | None = None, +) -> GenericAsset: + """Create or update a public root asset used as a template.""" + asset = db.session.execute( + select(GenericAsset).filter_by( + name=name, + account_id=None, + parent_asset_id=None, + ) + ).scalar_one_or_none() + + if asset is None: + asset = GenericAsset( + name=name, + generic_asset_type=asset_type, + account_id=None, + ) + db.session.add(asset) + db.session.flush() + + asset.generic_asset_type = asset_type + asset.description = description + if attributes: + asset.attributes = dict(asset.attributes or {}) + asset.attributes.update(attributes) + if flex_model is not None: + asset.flex_model = flex_model + if sensors_to_show is not None: + asset.sensors_to_show = sensors_to_show + return asset + + +def _ensure_sensor( + db: SQLAlchemy, + *, + asset: GenericAsset, + name: str, + unit: str, + event_resolution: timedelta, + attributes: dict | None = None, +) -> Sensor: + """Create or update one sensor for a template asset.""" + sensor = db.session.execute( + select(Sensor).filter_by(name=name, generic_asset_id=asset.id) + ).scalar_one_or_none() + if sensor is None: + sensor = Sensor( + name=name, + generic_asset=asset, + unit=unit, + timezone="Europe/Amsterdam", + event_resolution=event_resolution, + ) + db.session.add(sensor) + db.session.flush() + + sensor.unit = unit + sensor.timezone = "Europe/Amsterdam" + sensor.event_resolution = event_resolution + if attributes: + sensor.attributes = dict(sensor.attributes or {}) + sensor.attributes.update(attributes) + return sensor + + +def _template_metadata(template_key: str) -> dict: + return { + "template": { + "key": template_key, + "kind": "single-asset", + "has_scenarios": False, + } + } + + +@as_transaction +def provision_default_template_assets(db: SQLAlchemy): + """Ensure the default starter template assets exist. + + This currently provisions the single-asset starter templates which are + intended to show up in the asset copy UI. + """ + asset_types = add_default_asset_types(db) + + # Battery + battery = _ensure_public_root_asset( + db, + name="Battery Template", + asset_type=asset_types["battery"], + description=( + "Single battery asset with example power and state-of-charge sensors, " + "plus a basic storage flex-model. Copy this to start modeling a battery." + ), + attributes=_template_metadata("battery-template"), + ) + battery_power = _ensure_sensor( + db, + asset=battery, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + battery_soc = _ensure_sensor( + db, + asset=battery, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + battery.flex_model = { + "soc-max": "450 kWh", + "soc-min": "50 kWh", + "roundtrip-efficiency": "90%", + "power-capacity": "500 kW", + "state-of-charge": {"sensor": battery_soc.id}, + } + battery.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": battery_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": battery_soc.id}]}, + ] + + # EV charger + ev_charger = _ensure_public_root_asset( + db, + name="EV Charger Template", + asset_type=asset_types["one-way_evse"], + description=( + "Single EV charger asset with example charging power and state-of-charge " + "sensors, plus a simple charging flex-model. Copy this to start building " + "a charger or EV charging setup." + ), + attributes=_template_metadata("ev-charger-template"), + ) + ev_power = _ensure_sensor( + db, + asset=ev_charger, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + ev_soc = _ensure_sensor( + db, + asset=ev_charger, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + ev_charger.flex_model = { + "soc-max": "60 kWh", + "soc-min": "12 kWh", + "roundtrip-efficiency": "90%", + "power-capacity": "11 kW", + "production-capacity": "0 kW", + "state-of-charge": {"sensor": ev_soc.id}, + } + ev_charger.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": ev_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": ev_soc.id}]}, + ] + + # Heat pump / buffer + heat_pump = _ensure_public_root_asset( + db, + name="Heat Pump Template", + asset_type=asset_types["heat-storage"], + description=( + "Single heat-pump-with-buffer style asset, represented as thermal storage " + "with example power and thermal state-of-charge sensors. Copy this to " + "start modeling heat flexibility." + ), + attributes=_template_metadata("heat-pump-template"), + ) + heat_power = _ensure_sensor( + db, + asset=heat_pump, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + heat_soc = _ensure_sensor( + db, + asset=heat_pump, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + heat_pump.flex_model = { + "soc-max": "15 kWh", + "soc-min": "0 kWh", + "charging-efficiency": "300 %", + "storage-efficiency": "99.3 %", + "consumption-capacity": "5 kW", + "production-capacity": "0 kW", + "power-capacity": "5 kW", + "state-of-charge": {"sensor": heat_soc.id}, + } + heat_pump.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": heat_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": heat_soc.id}]}, + ] + + # ------------ Main functions -------------------------------- # These can registered at the app object as cli functions diff --git a/flexmeasures/data/tests/test_template_assets.py b/flexmeasures/data/tests/test_template_assets.py new file mode 100644 index 0000000000..fa7440b862 --- /dev/null +++ b/flexmeasures/data/tests/test_template_assets.py @@ -0,0 +1,71 @@ +from sqlalchemy import func, select + +from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.scripts.data_gen import provision_default_template_assets + + +def test_provision_default_template_assets_creates_single_asset_templates( + fresh_db, app +): + provision_default_template_assets(fresh_db) + + assets = fresh_db.session.scalars( + select(GenericAsset).where(GenericAsset.account_id.is_(None)) + ).all() + assets_by_name = {asset.name: asset for asset in assets} + + assert set(assets_by_name) >= { + "Battery Template", + "EV Charger Template", + "Heat Pump Template", + } + + battery = assets_by_name["Battery Template"] + assert battery.generic_asset_type.name == "battery" + assert battery.attributes["template"]["key"] == "battery-template" + assert battery.attributes["template"]["has_scenarios"] is False + assert battery.description.startswith("Single battery asset") + + ev_charger = assets_by_name["EV Charger Template"] + assert ev_charger.generic_asset_type.name == "one-way_evse" + assert ev_charger.attributes["template"]["key"] == "ev-charger-template" + assert ev_charger.description.startswith("Single EV charger asset") + + heat_pump = assets_by_name["Heat Pump Template"] + assert heat_pump.generic_asset_type.name == "heat-storage" + assert heat_pump.attributes["template"]["key"] == "heat-pump-template" + assert heat_pump.description.startswith("Single heat-pump-with-buffer style asset") + + battery_sensor_names = {sensor.name for sensor in battery.sensors} + ev_sensor_names = {sensor.name for sensor in ev_charger.sensors} + heat_sensor_names = {sensor.name for sensor in heat_pump.sensors} + + assert battery_sensor_names == {"electricity-power", "state-of-charge"} + assert ev_sensor_names == {"electricity-power", "state-of-charge"} + assert heat_sensor_names == {"electricity-power", "state-of-charge"} + + battery_soc_sensor = next( + sensor for sensor in battery.sensors if sensor.name == "state-of-charge" + ) + assert battery.flex_model["state-of-charge"]["sensor"] == battery_soc_sensor.id + + assert app.config["FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP"] is False + + +def test_provision_default_template_assets_is_idempotent(fresh_db): + provision_default_template_assets(fresh_db) + asset_count = fresh_db.session.scalar( + select(func.count()).select_from(GenericAsset) + ) + sensor_count = fresh_db.session.scalar(select(func.count()).select_from(Sensor)) + + provision_default_template_assets(fresh_db) + assert ( + fresh_db.session.scalar(select(func.count()).select_from(GenericAsset)) + == asset_count + ) + assert ( + fresh_db.session.scalar(select(func.count()).select_from(Sensor)) + == sensor_count + ) diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index e4d869054b..d19492142e 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -128,6 +128,7 @@ class Config(object): FLEXMEASURES_TIMEZONE: str = "Asia/Seoul" FLEXMEASURES_HIDE_NAN_IN_UI: bool = False FLEXMEASURES_PUBLIC_DEMO_CREDENTIALS: tuple | None = None + FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP: bool = True # Configuration used for entity addressing: # This setting contains the domain on which FlexMeasures runs # and the first month when the domain was under the current owner's administration @@ -258,6 +259,7 @@ class DevelopmentConfig(Config): class TestingConfig(Config): DEBUG: bool = True # this seems to be important for logging in, not sure why LOGGING_LEVEL: int = logging.INFO + FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP: bool = False WTF_CSRF_ENABLED: bool = False # also necessary for logging in during tests SECRET_KEY: str = "dummy-key-for-testing" From 85bed6436edf211d79fdb8507e175cbfb0b4a3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Mon, 6 Jul 2026 13:02:00 +0200 Subject: [PATCH 3/5] make copy-asset dialogue UX more supportive of templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolas Höning --- .../ui/templates/assets/asset_new.html | 85 +++++++++++++++++-- flexmeasures/ui/tests/test_asset_crud.py | 6 ++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_new.html b/flexmeasures/ui/templates/assets/asset_new.html index ee231cf916..477968297f 100644 --- a/flexmeasures/ui/templates/assets/asset_new.html +++ b/flexmeasures/ui/templates/assets/asset_new.html @@ -146,7 +146,7 @@

Location

+ placeholder="Search assets…" value="Template">