{% endblock %}
diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py
index acf7ba6d5f..206437eae0 100644
--- a/flexmeasures/ui/views/assets/views.py
+++ b/flexmeasures/ui/views/assets/views.py
@@ -254,6 +254,8 @@ def automations(self, id: str):
return render_flexmeasures_template(
"assets/asset_automations.html",
asset=asset,
+ # managing automations requires the same principals that may delete the asset
+ user_can_manage_automations=user_can_delete(asset),
current_page="Automations",
)
From 073abd9c284230ed22a70b654f0510504405b319 Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Sat, 11 Jul 2026 18:45:28 +0200
Subject: [PATCH 02/18] docs: changelog entry for automations CRUD
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01Rbix8k1JfeUWNXEmHEZVpX
---
documentation/changelog.rst | 1 +
1 file changed, 1 insertion(+)
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index de30b4a955..d9810f6054 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -14,6 +14,7 @@ New features
-------------
* Automations - first roundtrip for forecasts: recurring tasks defined per asset, managed with new CLI commands (``flexmeasures add|edit|delete automation``), run by ``flexmeasures jobs run-automations``, and viewable in a new UI page and API endpoints (``[GET] /assets/(id)/automations``); jobs now also record whether they were created via the CLI, the API or an automation [see `PR #2290 `_]
* Automations can also compute schedules on a recurring basis (``flexmeasures add automation --type schedules``), with the schedule start defaulting to each run's time [see `PR #2293 `_]
+* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by account admins and consultants [see `PR #2294 `_]
* Breaking behaviour change: the top-level flex-context's ``relax-constraints`` field now defaults to ``True`` (matching the default already used within each ``commodities`` entry), so constraint violations are softly penalized by default instead of being hard constraints, unless explicitly set to ``False`` [see `PR #2172 `_]
* In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 `_]
* Support configurable lower and upper bounds and snapping for forecast post-processing [see `PR #2273 `_]
From 2237245191cb84b5dc13b3e07b6064b057c6ede6 Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 18:59:46 +0200
Subject: [PATCH 03/18] data/services: only let a user automate sensors they
can access themselves
Context:
- Review of #2290 asked that automations administered through the UI (and hence
the API) may only involve sensors the creating user has access to; account
admin rights on the asset should not grant access to another account's sensors
Change:
- Work out the sensors an automation would read from and write to (forecasts:
the sensor to forecast plus its regressors, and the sensor to save to;
schedules: the flex-model's device sensors, and whatever the parameters refer to)
- Require read access to the former and create-children (the permission for
recording data through the API) on the latter, when creating via the API
- The CLI creates automations without a user, and stays unrestricted
Signed-off-by: F.N. Claessen
---
flexmeasures/api/v3_0/assets.py | 5 ++
flexmeasures/data/services/automations.py | 96 ++++++++++++++++++++++-
2 files changed, 100 insertions(+), 1 deletion(-)
diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py
index f76153d48e..9d58f614c4 100644
--- a/flexmeasures/api/v3_0/assets.py
+++ b/flexmeasures/api/v3_0/assets.py
@@ -1328,6 +1328,10 @@ def post_automation(self, id: int, asset: GenericAsset):
forecast parameters for type `forecasts`, or a schedule trigger message
(without the asset id) for type `schedules`.
Requires account admin or consultant rights.
+
+ The automation can only involve sensors that you have access to yourself:
+ read access to the sensors it reads data from, and permission to record data
+ on the sensors it writes to.
security:
- ApiKeyAuth: []
parameters:
@@ -1382,6 +1386,7 @@ def post_automation(self, id: int, asset: GenericAsset):
forecaster_class=automation_data["forecaster"],
config=automation_data["config"],
origin="API",
+ check_permissions=True,
)
except ValidationError as e:
return unprocessable_entity({"parameters": e.messages})
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index c74f20f7c9..f1b270a3ab 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -13,12 +13,69 @@
import pandas as pd
from sqlalchemy import select
+from werkzeug.exceptions import Forbidden
+
from flexmeasures import Forecaster
+from flexmeasures.auth.policy import check_access
from flexmeasures.data import db
from flexmeasures.data.models.automations import Automation
+from flexmeasures.data.models.time_series import Sensor
from flexmeasures.utils.time_utils import get_timezone, server_now
+def collect_sensors(
+ value: Any, sensors: dict[int, Sensor] | None = None
+) -> list[Sensor]:
+ """Collect the sensors referenced anywhere in a (possibly nested) structure.
+
+ Both deserialized sensors and the sensor references that survive deserialization
+ as raw data (e.g. in the flex-context, which is loaded as-is) are picked up.
+ """
+ if sensors is None:
+ sensors = {}
+ if isinstance(value, Sensor):
+ sensors[value.id] = value
+ elif isinstance(value, dict):
+ for key, item in value.items():
+ if key in ("sensor", "sensor-to-save") and isinstance(item, (int, str)):
+ sensor = (
+ db.session.get(Sensor, int(item)) if str(item).isdigit() else None
+ )
+ if sensor is not None:
+ sensors[sensor.id] = sensor
+ else:
+ collect_sensors(item, sensors)
+ elif isinstance(value, (list, tuple, set)):
+ for item in value:
+ collect_sensors(item, sensors)
+ return list(sensors.values())
+
+
+def check_sensor_access(
+ input_sensors: list[Sensor], output_sensors: list[Sensor]
+) -> None:
+ """Require access to the sensors that an automation would read from and write to.
+
+ Reading a sensor's data requires read access to it, and recording data on a sensor
+ requires the same permission as recording data through the API (create-children).
+ """
+ for sensors, permission, action in (
+ (input_sensors, "read", "read data from"),
+ (output_sensors, "create-children", "record data on"),
+ ):
+ for sensor in sensors:
+ try:
+ check_access(sensor, permission)
+ except Forbidden as exc:
+ setattr(
+ exc,
+ "api_message",
+ f"You cannot set up an automation that would {action} sensor"
+ f" {sensor.id} ({sensor.name}), because you cannot {action} it yourself.",
+ )
+ raise
+
+
def describe_cronstr(cronstr: str) -> str:
"""Describe a cron string in natural language, e.g. "At 06:00".
@@ -130,14 +187,21 @@ def create_automation(
config: dict | None = None,
source=None,
origin: str = "API",
+ check_permissions: bool = False,
) -> tuple[Automation, list[str]]:
"""Create an automation (not committed yet), validating its parameters by type.
For forecasts, the forecaster config is stored on a data source.
An audit log record is added to the asset.
+ :param check_permissions: whether to require that the current user may read the
+ sensors that the automation reads from, and record data
+ on the sensors it writes to. Set this for automations
+ created by a user (through the API or the UI); the CLI
+ runs without a user, and is trusted.
:raises marshmallow.ValidationError: if the parameters are invalid.
:raises ValueError: if the forecaster cannot be set up.
+ :raises werkzeug.exceptions.Forbidden: if a sensor is not accessible to the user.
:returns: the automation and a list of warnings.
"""
from marshmallow import ValidationError
@@ -148,6 +212,8 @@ def create_automation(
parameters = parameters or {}
warnings: list[str] = []
generator_id = None
+ input_sensors: list[Sensor] = []
+ output_sensors: list[Sensor] = []
if automation_type == "forecasts":
from flexmeasures.data.schemas.forecasting.pipeline import (
ForecasterParametersSchema,
@@ -174,12 +240,37 @@ def create_automation(
) # looks up or creates the data source storing the forecaster config
db.session.flush()
generator_id = generator.id
+
+ # A forecast reads the history of the sensor to forecast, plus its regressors,
+ # and records the forecast on the sensor to save to (the same sensor by default).
+ output_sensor = deserialized_parameters.get("sensor_to_save") or sensor
+ output_sensors = collect_sensors(output_sensor)
+ input_sensors = collect_sensors(
+ [
+ sensor,
+ forecaster._config.get("past_regressors"),
+ forecaster._config.get("future_regressors"),
+ forecaster._config.get("regressors"),
+ ]
+ )
elif automation_type == "schedules":
from flexmeasures.data.schemas.scheduling import AssetTriggerSchema
- AssetTriggerSchema().load(
+ message = AssetTriggerSchema().load(
prepare_schedule_trigger_message(parameters, asset.id)
)
+
+ # A schedule is recorded on the power sensor of each device in the flex-model,
+ # and reads whatever sensors the flex-model and flex-context refer to.
+ output_sensors = collect_sensors(
+ [device.get("sensor") for device in message.get("flex_model", [])]
+ )
+ output_sensor_ids = [sensor.id for sensor in output_sensors]
+ input_sensors = [
+ sensor
+ for sensor in collect_sensors(parameters)
+ if sensor.id not in output_sensor_ids
+ ]
if "start" in parameters:
warnings.append(
"The schedule 'start' is fixed, so each run will compute the same period."
@@ -190,6 +281,9 @@ def create_automation(
f"Automation type '{automation_type}' is not supported (supported types: {Automation.SUPPORTED_TYPES})."
)
+ if check_permissions:
+ check_sensor_access(input_sensors, output_sensors)
+
automation = Automation(
asset_id=asset.id,
type=automation_type,
From 4765fd83de896d7f1ce708902ab597e06433cba1 Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 18:59:47 +0200
Subject: [PATCH 04/18] api/v3_0: regenerate the OpenAPI specs
Context:
- The endpoint description now states the sensor access rule
Change:
- Regenerated the specs
Signed-off-by: F.N. Claessen
---
flexmeasures/ui/static/openapi-specs.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json
index 65ce33c148..9dd80a3a7c 100644
--- a/flexmeasures/ui/static/openapi-specs.json
+++ b/flexmeasures/ui/static/openapi-specs.json
@@ -3361,7 +3361,7 @@
},
"post": {
"summary": "Create an automation on an asset.",
- "description": "Create a recurring task (computing forecasts or schedules) on the asset.\nThe parameters are validated by the schema matching the automation type:\nforecast parameters for type `forecasts`, or a schedule trigger message\n(without the asset id) for type `schedules`.\nRequires account admin or consultant rights.\n",
+ "description": "Create a recurring task (computing forecasts or schedules) on the asset.\nThe parameters are validated by the schema matching the automation type:\nforecast parameters for type `forecasts`, or a schedule trigger message\n(without the asset id) for type `schedules`.\nRequires account admin or consultant rights.\n\nThe automation can only involve sensors that you have access to yourself:\nread access to the sensors it reads data from, and permission to record data\non the sensors it writes to.\n",
"security": [
{
"ApiKeyAuth": []
From 5d92ea069b0f8ff4512b08c99a337f0aa06f9b04 Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 18:59:47 +0200
Subject: [PATCH 05/18] api/v3_0/tests: cover automating an inaccessible sensor
Context:
- The sensor access rule for created automations needs regression coverage
Change:
- An account admin creating an automation on another account's sensor gets a 403
naming that sensor, and no automation is created; the same request on their own
sensor still succeeds (verified to fail without the check)
Signed-off-by: F.N. Claessen
---
.../api/v3_0/tests/test_automations_api.py | 64 +++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index f1481c4a64..f24223b3d4 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -2,10 +2,14 @@
from __future__ import annotations
+from datetime import timedelta
+
import pytest
from flask import url_for
+from sqlalchemy import select
from flexmeasures.data.models.automations import Automation
+from flexmeasures.data.models.time_series import Sensor
@pytest.fixture(scope="module")
@@ -215,6 +219,66 @@ def test_post_automation_with_invalid_parameters(
assert "sensor" in str(response.json)
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_post_automation_with_inaccessible_sensor(
+ app,
+ db,
+ add_battery_assets,
+ setup_generic_assets,
+ requesting_user,
+):
+ """An account admin cannot set up an automation on a sensor of another account."""
+ battery = add_battery_assets["Test battery"]
+ someone_elses_sensor = Sensor(
+ name="wind speed",
+ generic_asset=setup_generic_assets[
+ "test_wind_turbine"
+ ], # owned by the Supplier account
+ event_resolution=timedelta(minutes=15),
+ unit="m/s",
+ )
+ db.session.add(someone_elses_sensor)
+ db.session.flush()
+
+ with app.test_client() as client:
+ response = client.post(
+ url_for("AssetAPI:post_automation", id=battery.id),
+ json={
+ "name": "Forecasts of another account's sensor",
+ "cronstr": "0 6 * * *",
+ "type": "forecasts",
+ "parameters": {"sensor": someone_elses_sensor.id},
+ },
+ )
+ assert response.status_code == 403
+ assert str(someone_elses_sensor.id) in response.json["message"]
+ assert (
+ db.session.execute(
+ select(Automation).filter_by(name="Forecasts of another account's sensor")
+ ).scalar_one_or_none()
+ is None
+ )
+
+ # the same automation on their own sensor is fine
+ own_sensor = battery.sensors[0]
+ with app.test_client() as client:
+ response = client.post(
+ url_for("AssetAPI:post_automation", id=battery.id),
+ json={
+ "name": "Forecasts of their own sensor",
+ "cronstr": "0 6 * * *",
+ "type": "forecasts",
+ "parameters": {"sensor": own_sensor.id},
+ },
+ )
+ assert response.status_code == 201, response.json
+ # clean up for other tests in this module
+ db.session.delete(db.session.get(Automation, response.json["id"]))
+ db.session.flush()
+
+
@pytest.mark.parametrize(
"requesting_user, expected_status_code",
[
From d4bf80ef82fa50b9fc2c5c0503c6a2e9f823c87a Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 18:59:48 +0200
Subject: [PATCH 06/18] docs: describe which sensors an automation may involve
Context:
- The sensor access rule is user-facing
Change:
- Documented it in the forecasting feature docs, the changelog entry of #2294
and the API change log
Signed-off-by: F.N. Claessen
---
documentation/api/change_log.rst | 1 +
documentation/changelog.rst | 2 +-
documentation/features/forecasting.rst | 3 +++
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst
index c75a260f70..7745c02f74 100644
--- a/documentation/api/change_log.rst
+++ b/documentation/api/change_log.rst
@@ -8,6 +8,7 @@ API change log
v3.0-32 | July XX, 2026
""""""""""""""""""""""""
+- Added ``POST /api/v3_0/assets//automations``, ``PATCH /api/v3_0/assets//automations/`` and ``DELETE /api/v3_0/assets//automations/`` for managing an asset's automations. They require account admin or consultant rights, and an automation may only involve sensors that its creator can access: read access to the sensors it reads data from, and permission to record data on the sensors it writes to (a ``403`` otherwise).
- Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined.
v3.0-31 | 2026-06-01
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index d9810f6054..4860e5130d 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -14,7 +14,7 @@ New features
-------------
* Automations - first roundtrip for forecasts: recurring tasks defined per asset, managed with new CLI commands (``flexmeasures add|edit|delete automation``), run by ``flexmeasures jobs run-automations``, and viewable in a new UI page and API endpoints (``[GET] /assets/(id)/automations``); jobs now also record whether they were created via the CLI, the API or an automation [see `PR #2290 `_]
* Automations can also compute schedules on a recurring basis (``flexmeasures add automation --type schedules``), with the schedule start defaulting to each run's time [see `PR #2293 `_]
-* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by account admins and consultants [see `PR #2294 `_]
+* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by account admins and consultants, and only involving sensors they can access themselves (read access to the sensors an automation reads, and permission to record data on the sensors it writes to) [see `PR #2294 `_]
* Breaking behaviour change: the top-level flex-context's ``relax-constraints`` field now defaults to ``True`` (matching the default already used within each ``commodities`` entry), so constraint violations are softly penalized by default instead of being hard constraints, unless explicitly set to ``False`` [see `PR #2172 `_]
* In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 `_]
* Support configurable lower and upper bounds and snapping for forecast post-processing [see `PR #2273 `_]
diff --git a/documentation/features/forecasting.rst b/documentation/features/forecasting.rst
index d492488158..dc63043776 100644
--- a/documentation/features/forecasting.rst
+++ b/documentation/features/forecasting.rst
@@ -206,5 +206,8 @@ The jobs record how they were created, which is shown on the asset's status page
Automations defined on an asset can be viewed on the asset's *Automations* page in the UI, and listed with the API endpoint `[GET] /assets/(id)/automations <../api/v3_0.html>`_.
Account admins and consultants can also create, (de)activate and delete automations right there on the page,
or through the API (`[POST] /assets/(id)/automations`, `[PATCH] /assets/(id)/automations/(automation_id)` and `[DELETE] /assets/(id)/automations/(automation_id)`).
+An automation created this way can only involve sensors that its creator can access themselves:
+they need read access to the sensors it reads data from, and permission to record data on the sensors it writes to.
+The CLI is not restricted in this way.
Schedules can be automated in the same way — see :ref:`automating_schedules`.
From d8701c600233125163ca4e5cd9c35a4ac625bc9e Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 23:33:35 +0200
Subject: [PATCH 07/18] data/services: check every sensor a schedule would be
recorded on
Context:
- Schedulers hand their results to make_schedule as (sensor, data) pairs, and
those sensors are not only the flex-model's device sensors: a schedule is also
recorded on a device's state-of-charge, consumption and production sensors, and
on the flex-context's aggregate-consumption and aggregate-production sensors
Change:
- Derive a schedule's output sensors from all the fields that name where generated
data goes, at any depth in the flex-model and flex-context (which schedulers
deserialize themselves, so their sensor references are still raw)
- Everything else the parameters refer to (e.g. price sensors and the sensors of
inflexible devices, which may also live on the flex-context) counts as an input
Signed-off-by: F.N. Claessen
---
flexmeasures/data/services/automations.py | 84 ++++++++++++++++++-----
1 file changed, 67 insertions(+), 17 deletions(-)
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index f1b270a3ab..3d9ae2b012 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -22,32 +22,83 @@
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.utils.time_utils import get_timezone, server_now
+# Fields naming a sensor that a scheduler records its results on, rather than reads from.
+# A scheduler hands its results to `make_schedule` as (sensor, data) pairs, and these are
+# the fields that decide which sensors those are: besides the power sensor of each device
+# in the flex-model, its state of charge and its consumption and production sensors, plus
+# the aggregates over all devices, which are defined in the flex-context.
+OUTPUT_SENSOR_FIELDS = (
+ "consumption",
+ "production",
+ "state-of-charge",
+ "state_of_charge",
+ "aggregate-consumption",
+ "aggregate_consumption",
+ "aggregate-production",
+ "aggregate_production",
+)
+
def collect_sensors(
- value: Any, sensors: dict[int, Sensor] | None = None
+ value: Any,
+ sensors: dict[int, Sensor] | None = None,
+ only_under_output_field: bool = False,
+ _under_output_field: bool = False,
) -> list[Sensor]:
"""Collect the sensors referenced anywhere in a (possibly nested) structure.
Both deserialized sensors and the sensor references that survive deserialization
- as raw data (e.g. in the flex-context, which is loaded as-is) are picked up.
+ as raw data (e.g. the flex-context and each device's flex-model, which schedulers
+ deserialize themselves) are picked up.
+
+ :param only_under_output_field: only collect the sensors that are referenced under
+ one of the OUTPUT_SENSOR_FIELDS, at any depth.
"""
if sensors is None:
sensors = {}
+
+ def collect(sensor: Sensor | None):
+ if sensor is not None and (_under_output_field or not only_under_output_field):
+ sensors[sensor.id] = sensor
+
if isinstance(value, Sensor):
- sensors[value.id] = value
+ collect(value)
elif isinstance(value, dict):
for key, item in value.items():
- if key in ("sensor", "sensor-to-save") and isinstance(item, (int, str)):
- sensor = (
- db.session.get(Sensor, int(item)) if str(item).isdigit() else None
- )
- if sensor is not None:
- sensors[sensor.id] = sensor
+ under_output_field = _under_output_field or key in OUTPUT_SENSOR_FIELDS
+ if key == "sensor" and isinstance(item, (int, str)):
+ # a sensor reference that was not deserialized, e.g. {"sensor": 12}
+ if str(item).isdigit():
+ sensor = db.session.get(Sensor, int(item))
+ if sensor is not None and (
+ under_output_field or not only_under_output_field
+ ):
+ sensors[sensor.id] = sensor
else:
- collect_sensors(item, sensors)
+ collect_sensors(
+ item, sensors, only_under_output_field, under_output_field
+ )
elif isinstance(value, (list, tuple, set)):
for item in value:
- collect_sensors(item, sensors)
+ collect_sensors(item, sensors, only_under_output_field, _under_output_field)
+ return list(sensors.values())
+
+
+def collect_schedule_output_sensors(message: dict) -> list[Sensor]:
+ """The sensors that scheduling with this trigger message would record data on.
+
+ That is the power sensor of each device in the flex-model, plus any sensor named by
+ a field that defines where generated data goes (see OUTPUT_SENSOR_FIELDS), both per
+ device and, for the aggregates, in the flex-context.
+ """
+ sensors: dict[int, Sensor] = {}
+ for device in message.get("flex_model") or []:
+ # each device's power sensor is what its schedule is recorded on
+ collect_sensors(device.get("sensor"), sensors)
+ collect_sensors(
+ device.get("sensor_flex_model"), sensors, only_under_output_field=True
+ )
+ collect_sensors(message.get("flex_context"), sensors, only_under_output_field=True)
return list(sensors.values())
@@ -260,15 +311,14 @@ def create_automation(
prepare_schedule_trigger_message(parameters, asset.id)
)
- # A schedule is recorded on the power sensor of each device in the flex-model,
- # and reads whatever sensors the flex-model and flex-context refer to.
- output_sensors = collect_sensors(
- [device.get("sensor") for device in message.get("flex_model", [])]
- )
+ # A schedule is recorded on the sensors that the scheduler returns its results
+ # for, and reads whatever other sensors the flex-model and flex-context refer to
+ # (such as price sensors and the sensors of inflexible devices).
+ output_sensors = collect_schedule_output_sensors(message)
output_sensor_ids = [sensor.id for sensor in output_sensors]
input_sensors = [
sensor
- for sensor in collect_sensors(parameters)
+ for sensor in collect_sensors(message)
if sensor.id not in output_sensor_ids
]
if "start" in parameters:
From 05e00aca75c1312cbded4bc98c228a51bcfee81b Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Wed, 5 Aug 2026 23:33:36 +0200
Subject: [PATCH 08/18] api/v3_0/tests: cover a schedule aggregated onto an
inaccessible sensor
Context:
- The flex-context's aggregate-consumption sensor is written to, so it needs the
same check as the flex-model's own sensors
Change:
- Posting such an automation gets a 403 that names the sensor and the action
(verified to fail when only the flex-model's sensors are treated as outputs)
Signed-off-by: F.N. Claessen
---
.../api/v3_0/tests/test_automations_api.py | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index f24223b3d4..0aa8b9b287 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -279,6 +279,53 @@ def test_post_automation_with_inaccessible_sensor(
db.session.flush()
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_post_schedule_automation_with_inaccessible_output_sensor(
+ app,
+ db,
+ add_battery_assets,
+ setup_generic_assets,
+ requesting_user,
+):
+ """Sensors that a schedule would be recorded on are checked, wherever they are named.
+
+ The aggregate power schedule is recorded on the flex-context's aggregate-consumption
+ sensor, so that one needs to be writable, too — not just the flex-model's own sensors.
+ """
+ battery = add_battery_assets["Test battery"]
+ someone_elses_sensor = Sensor(
+ name="aggregate consumption",
+ generic_asset=setup_generic_assets[
+ "test_wind_turbine"
+ ], # owned by the Supplier account
+ event_resolution=timedelta(minutes=15),
+ unit="MW",
+ )
+ db.session.add(someone_elses_sensor)
+ db.session.flush()
+
+ with app.test_client() as client:
+ response = client.post(
+ url_for("AssetAPI:post_automation", id=battery.id),
+ json={
+ "name": "Schedules aggregated onto another account's sensor",
+ "cronstr": "0 0 * * *",
+ "type": "schedules",
+ "parameters": {
+ "duration": "PT12H",
+ "flex-context": {
+ "aggregate-consumption": {"sensor": someone_elses_sensor.id}
+ },
+ },
+ },
+ )
+ assert response.status_code == 403
+ assert str(someone_elses_sensor.id) in response.json["message"]
+ assert "record data on" in response.json["message"]
+
+
@pytest.mark.parametrize(
"requesting_user, expected_status_code",
[
From 64ad898c3f730b54db7b7838036bf5dd996b9c63 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Tue, 11 Aug 2026 17:02:52 +0100
Subject: [PATCH 09/18] data/services: let the forecaster say which sensors an
automation involves
A regressor that filters on sources deserializes into a sensor reference rather than a sensor,
which collect_sensors skipped, so such a regressor was left out of the sensors an automation reads from.
The access check is built on that list, so a user could set up an automation reading a sensor they cannot read themselves.
Ask the forecaster instead, as it derives its input and output sensors from the same config and parameters it will run with,
and already resolves sensor references. Schedules keep their own collection, as they have no data generator to ask.
Displaying the sensors involved and checking access to them now share one implementation, so they cannot disagree.
Co-Authored-By: Claude Opus 5
Signed-off-by: Mohamed Belhsan Hmida
---
.../api/v3_0/tests/test_automations_api.py | 53 +++++++++++++++++++
flexmeasures/data/services/automations.py | 49 ++++++++++-------
2 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index 07c272736f..e43e4cc44f 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -239,6 +239,59 @@ def test_post_automation_with_invalid_parameters(
assert "sensor" in str(response.json)
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_post_automation_with_inaccessible_source_filtered_regressor(
+ app,
+ db,
+ add_battery_assets,
+ setup_generic_assets,
+ requesting_user,
+):
+ """A regressor that filters on sources is a sensor reference, and still counts as a sensor read."""
+ battery = add_battery_assets["Test battery"]
+ someone_elses_sensor = Sensor(
+ name="wind speed for a filtered regressor",
+ generic_asset=setup_generic_assets[
+ "test_wind_turbine"
+ ], # owned by the Supplier account
+ event_resolution=timedelta(minutes=15),
+ unit="m/s",
+ )
+ db.session.add(someone_elses_sensor)
+ db.session.flush()
+
+ with app.test_client() as client:
+ response = client.post(
+ url_for("AssetAPI:post_automation", id=battery.id),
+ json={
+ "name": "Forecasts regressing on another account's sensor",
+ "cronstr": "0 6 * * *",
+ "type": "forecasts",
+ "parameters": {"sensor": battery.sensors[0].id},
+ "config": {
+ "regressors": [
+ {
+ "sensor": someone_elses_sensor.id,
+ "source-types": ["forecaster"],
+ }
+ ]
+ },
+ },
+ )
+ assert response.status_code == 403
+ assert str(someone_elses_sensor.id) in response.json["message"]
+ assert (
+ db.session.execute(
+ select(Automation).filter_by(
+ name="Forecasts regressing on another account's sensor"
+ )
+ ).scalar_one_or_none()
+ is None
+ )
+
+
@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index fbcaf46c47..89a9de874e 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -346,6 +346,26 @@ class AutomationSensorsUnknown(Exception):
"""
+def resolve_data_generator_sensors(
+ data_generator, deserialized_parameters: dict
+) -> dict[str, list[Sensor]]:
+ """Ask a data generator which sensors it would read from and write to, given these parameters.
+
+ A data generator derives this from its own config and parameters, so it also picks up a regressor that filters on sources,
+ which is a sensor reference rather than a plain sensor.
+ Work out the answer here rather than in each caller, so that displaying the sensors involved
+ and checking access to them can never disagree about what they are.
+ """
+ # Work on a copy, as the data generator is cached on the data source,
+ # which may be shared by several automations.
+ data_generator = copy(data_generator)
+ data_generator._parameters = deserialized_parameters
+ return {
+ "input_sensors": data_generator.input_sensors,
+ "output_sensors": data_generator.output_sensors,
+ }
+
+
def resolve_automation_sensors(automation: Automation) -> dict[str, list[Sensor]]:
"""Work out which sensors an automation reads from and writes to on each run.
@@ -360,16 +380,11 @@ def resolve_automation_sensors(automation: Automation) -> dict[str, list[Sensor]
f"Automation {automation.id} has no data generator, so the sensors it involves are unknown."
)
try:
- # Work on a copy, as the data generator is cached on the data source,
- # which may be shared by several automations.
- data_generator = copy(automation.generator.data_generator)
- data_generator._parameters = data_generator._parameters_schema.load(
- dict(automation.parameters or {})
+ data_generator = automation.generator.data_generator
+ return resolve_data_generator_sensors(
+ data_generator,
+ data_generator._parameters_schema.load(dict(automation.parameters or {})),
)
- return {
- "input_sensors": data_generator.input_sensors,
- "output_sensors": data_generator.output_sensors,
- }
except (NotImplementedError, ValidationError) as e:
raise AutomationSensorsUnknown(
f"Could not determine the sensors of automation {automation.id}: {e}"
@@ -572,17 +587,13 @@ def create_automation(
# A forecast reads the history of the sensor to forecast, plus its regressors,
# and records the forecast on the sensor to save to (the same sensor by default).
- output_sensor = deserialized_parameters.get("sensor_to_save") or sensor
- forecast_output_sensor = output_sensor
- output_sensors = collect_sensors(output_sensor)
- input_sensors = collect_sensors(
- [
- sensor,
- forecaster._config.get("past_regressors"),
- forecaster._config.get("future_regressors"),
- forecaster._config.get("regressors"),
- ]
+ # The forecaster works this out from the same config and parameters it will run with.
+ forecast_sensors = resolve_data_generator_sensors(
+ forecaster, deserialized_parameters
)
+ input_sensors = forecast_sensors["input_sensors"]
+ output_sensors = forecast_sensors["output_sensors"]
+ forecast_output_sensor = output_sensors[0] if output_sensors else None
elif automation_type == "schedules":
from flexmeasures.data.schemas.scheduling import AssetTriggerSchema
From 7a3c38ace39982c208eb89a837d04b019c5240d7 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Tue, 11 Aug 2026 17:06:27 +0100
Subject: [PATCH 10/18] api/v3_0: let an automation's timezone be set and
changed through the API
An automation carries the timezone its cron expression is interpreted in, and the CLI can set and change it,
but the API could do neither, so every automation created through the API or the UI was stuck on the server's timezone.
Both the creation and the update schema now accept a timezone, defaulting to FLEXMEASURES_TIMEZONE on creation.
Also restores the OpenAPI spec's version string, which a regeneration during the merge had replaced with the locally installed version.
Co-Authored-By: Claude Opus 5
Signed-off-by: Mohamed Belhsan Hmida
---
documentation/api/change_log.rst | 2 +-
flexmeasures/api/v3_0/assets.py | 1 +
.../api/v3_0/tests/test_automations_api.py | 54 +++++++++++++++++++
flexmeasures/data/schemas/automations.py | 18 ++++++-
flexmeasures/ui/static/openapi-specs.json | 16 +++++-
5 files changed, 88 insertions(+), 3 deletions(-)
diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst
index f0253d2ef4..4af1ca31ce 100644
--- a/documentation/api/change_log.rst
+++ b/documentation/api/change_log.rst
@@ -14,7 +14,7 @@ v3.0-32 | July XX, 2026
- Introduced the ``inflexible-consumption`` and ``inflexible-production`` flex-context fields, which make explicit how the sign of each inflexible device's power data should be read: positive values denote consumption resp. production. Each entry is a sensor reference (``{"sensor": }``), optionally with source filters (``source-types``, ``exclude-source-types``, ``sources``, ``source-account``). Deprecated the ``inflexible-device-sensors`` field (a list of bare sensor IDs, whose sign convention is read from each sensor's ``consumption_is_positive`` attribute); it remains supported, but cannot be combined with the new fields in one flex-context.
- Added a ``role`` query parameter to ``GET /api/v3_0/accounts`` for filtering accessible organisations by account role.
- Sensor references on variable-quantity flex-model and flex-context fields (such as ``soc-minima``, ``soc-maxima``, the capacity fields and the price fields) may now include a ``default`` fallback quantity, e.g. ``{"sensor": 50, "default": "0 kWh"}``. It fills the time slots for which the referenced sensor holds no value. Note that this fills *every* such slot, so a sensor recording only occasional setpoints becomes densely constrained. The field is not (yet) applied to sensor references on ``inflexible-consumption``/``inflexible-production`` or to forecaster regressors. Take particular care with a fallback of ``0`` on a ``consumption-capacity`` or ``production-capacity``: if the sensor holds no value for the whole scheduling window, the resulting all-zero capacity is read as a physical statement about the device and enforced strictly (see :ref:`the flex-model capacity fields `), rather than as a limit that may be breached at a price.
-- Added ``POST /api/v3_0/assets//automations``, ``PATCH /api/v3_0/assets//automations/`` and ``DELETE /api/v3_0/assets//automations/`` for managing an asset's automations. They require account admin or consultant rights, and an automation may only involve sensors that its creator can access: read access to the sensors it reads data from, and permission to record data on the sensors it writes to (a ``403`` otherwise).
+- Added ``POST /api/v3_0/assets//automations``, ``PATCH /api/v3_0/assets//automations/`` and ``DELETE /api/v3_0/assets//automations/`` for managing an asset's automations. They require account admin or consultant rights, and an automation may only involve sensors that its creator can access: read access to the sensors it reads data from, and permission to record data on the sensors it writes to (a ``403`` otherwise). Both the creation and the update accept a ``timezone``, in which the automation's cron expression is interpreted; it defaults to the server's ``FLEXMEASURES_TIMEZONE``.
- Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined.
- **Field canonicalization** for background job tracking:
* The ``job`` field is now the canonical way to identify background jobs returned by `/sensors//schedules/trigger`, `/assets//schedules/trigger`, and `/sensors//forecasts/trigger` endpoints. If applicable, the triggered response now also returns a ``results-url`` pointing to the sensor-specific results endpoint, alongside the generic ``job-url``.
diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py
index 5e0cfa2bc4..3387d2b600 100644
--- a/flexmeasures/api/v3_0/assets.py
+++ b/flexmeasures/api/v3_0/assets.py
@@ -1641,6 +1641,7 @@ def post_automation(self, id: int, asset: GenericAsset):
asset=asset,
name=automation_data["name"],
cronstr=automation_data["cronstr"],
+ timezone=automation_data["timezone"],
automation_type=automation_data["type"],
active=automation_data["active"],
parameters=automation_data["parameters"],
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index e43e4cc44f..c1853afed6 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -239,6 +239,60 @@ def test_post_automation_with_invalid_parameters(
assert "sensor" in str(response.json)
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_post_and_patch_automation_timezone(
+ app,
+ db,
+ add_battery_assets,
+ requesting_user,
+):
+ """An automation's timezone can be set on creation and changed afterwards, as it can from the CLI."""
+ battery = add_battery_assets["Test battery"]
+ with app.test_client() as client:
+ response = client.post(
+ url_for("AssetAPI:post_automation", id=battery.id),
+ json={
+ "name": "Seoul forecasts",
+ "cronstr": "0 6 * * *",
+ "timezone": "Asia/Seoul",
+ "type": "forecasts",
+ "parameters": {"sensor": battery.sensors[0].id},
+ },
+ )
+ assert response.status_code == 201, response.json
+ assert response.json["timezone"] == "Asia/Seoul"
+ automation = db.session.execute(
+ select(Automation).filter_by(name="Seoul forecasts")
+ ).scalar_one()
+ assert automation.timezone == "Asia/Seoul"
+
+ with app.test_client() as client:
+ response = client.patch(
+ url_for(
+ "AssetAPI:patch_automation",
+ id=battery.id,
+ automation_id=automation.id,
+ ),
+ json={"timezone": "Europe/Amsterdam"},
+ )
+ assert response.status_code == 200, response.json
+ assert response.json["timezone"] == "Europe/Amsterdam"
+ assert automation.timezone == "Europe/Amsterdam"
+
+ with app.test_client() as client:
+ response = client.patch(
+ url_for(
+ "AssetAPI:patch_automation",
+ id=battery.id,
+ automation_id=automation.id,
+ ),
+ json={"timezone": "Europe/NotAmsterdam"},
+ )
+ assert response.status_code == 422
+
+
@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
diff --git a/flexmeasures/data/schemas/automations.py b/flexmeasures/data/schemas/automations.py
index 17d806f884..d7762fa294 100644
--- a/flexmeasures/data/schemas/automations.py
+++ b/flexmeasures/data/schemas/automations.py
@@ -67,6 +67,13 @@ class AutomationCreationSchema(Schema):
)
name = fields.Str(required=True, validate=validate.Length(min=1, max=80))
cronstr = CronField(required=True)
+ timezone = TimezoneField(
+ load_default=None,
+ metadata={
+ "description": "IANA timezone in which the cron expression is interpreted. Defaults to the server's FLEXMEASURES_TIMEZONE.",
+ "example": "Europe/Amsterdam",
+ },
+ )
active = fields.Bool(load_default=True)
parameters = fields.Dict(keys=fields.Str(), load_default=dict)
forecaster = fields.Str(
@@ -83,10 +90,19 @@ class AutomationCreationSchema(Schema):
class AutomationUpdateSchema(Schema):
- """Request schema for updating an automation's name, cron string and/or activation status."""
+ """Request schema for updating an automation's name, recurrence, timezone and/or activation status.
+
+ The parameters cannot be updated, so the sensors an automation involves stay the ones its creator was checked against.
+ """
name = fields.Str(validate=validate.Length(min=1, max=80))
cronstr = CronField()
+ timezone = TimezoneField(
+ metadata={
+ "description": "IANA timezone in which the cron expression is interpreted.",
+ "example": "Europe/Amsterdam",
+ }
+ )
active = fields.Bool()
diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json
index 6e1a043b6d..3ff1b026fc 100644
--- a/flexmeasures/ui/static/openapi-specs.json
+++ b/flexmeasures/ui/static/openapi-specs.json
@@ -7,7 +7,7 @@
},
"termsOfService": null,
"title": "FlexMeasures",
- "version": "0.33.2"
+ "version": "1.0.0"
},
"externalDocs": {
"description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.",
@@ -6433,6 +6433,15 @@
"cronstr": {
"type": "string"
},
+ "timezone": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null,
+ "description": "IANA timezone in which the cron expression is interpreted. Defaults to the server's FLEXMEASURES_TIMEZONE.",
+ "example": "Europe/Amsterdam"
+ },
"active": {
"type": "boolean",
"default": true
@@ -6469,6 +6478,11 @@
"cronstr": {
"type": "string"
},
+ "timezone": {
+ "type": "string",
+ "description": "IANA timezone in which the cron expression is interpreted.",
+ "example": "Europe/Amsterdam"
+ },
"active": {
"type": "boolean"
}
From 561238701af06000166f92872091587a2a057e47 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Tue, 11 Aug 2026 17:20:08 +0100
Subject: [PATCH 11/18] data/services: set up the forecaster's data source only
once the automation is allowed
Creating an automation looked up or created the data source holding the forecaster configuration before checking
whether the user may involve the sensors at all, so a refused request still added a data source within that request.
Nothing committed in between, so this did not outlive the request, but it relied on that rather than on the order of events.
The data source is now set up after the access check, which makes a refused request leave nothing behind by construction.
Also records what the output sensor field list approximates, namely the sensors a scheduler returns results for at run time,
and therefore how it can drift away from them.
Co-Authored-By: Claude Opus 5
Signed-off-by: Mohamed Belhsan Hmida
---
.../api/v3_0/tests/test_automations_api.py | 3 +++
flexmeasures/data/services/automations.py | 19 ++++++++++++++-----
2 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index c1853afed6..331b75e808 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -315,6 +315,7 @@ def test_post_automation_with_inaccessible_source_filtered_regressor(
)
db.session.add(someone_elses_sensor)
db.session.flush()
+ data_sources_before = set(db.session.scalars(select(DataSource.id)).all())
with app.test_client() as client:
response = client.post(
@@ -344,6 +345,8 @@ def test_post_automation_with_inaccessible_source_filtered_regressor(
).scalar_one_or_none()
is None
)
+ # a refused request also leaves behind no data source for the forecaster it would have run
+ assert set(db.session.scalars(select(DataSource.id)).all()) == data_sources_before
@pytest.mark.parametrize(
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index 89a9de874e..9a39217af8 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -45,6 +45,12 @@ class DueAutomation:
# the fields that decide which sensors those are: besides the power sensor of each device
# in the flex-model, its state of charge and its consumption and production sensors, plus
# the aggregates over all devices, which are defined in the flex-context.
+#
+# NB this list restates at set-up time what a scheduler decides at run time, so the two can drift apart.
+# A scheduler that starts returning results for a sensor named by some other field would write to a sensor
+# that was never checked against the creator's permissions, as this reads that sensor as an input instead.
+# Extend this list whenever a flex-model or flex-context field starts naming somewhere results are recorded.
+# Checking the sensors a scheduler actually returns, rather than the ones predicted here, would close the gap for good.
OUTPUT_SENSOR_FIELDS = (
"consumption",
"production",
@@ -555,6 +561,7 @@ def create_automation(
parameters = parameters or {}
warnings: list[str] = []
generator_id = None
+ forecaster = None
input_sensors: list[Sensor] = []
output_sensors: list[Sensor] = []
forecast_output_sensor: Sensor | None = None
@@ -579,11 +586,6 @@ def create_automation(
)
if forecaster is None:
raise ValueError(f"Could not set up forecaster '{forecaster_class}'.")
- generator = (
- forecaster.data_source
- ) # looks up or creates the data source storing the forecaster config
- db.session.flush()
- generator_id = generator.id
# A forecast reads the history of the sensor to forecast, plus its regressors,
# and records the forecast on the sensor to save to (the same sensor by default).
@@ -629,6 +631,13 @@ def create_automation(
if forecast_output_sensor is not None:
validate_forecast_output_scope(asset.id, forecast_output_sensor)
+ if forecaster is not None:
+ # Look up or create the data source storing the forecaster config only now that the automation is going ahead,
+ # so that a refused request leaves nothing behind, whatever the caller does with the session afterwards.
+ generator = forecaster.data_source
+ db.session.flush()
+ generator_id = generator.id
+
automation_fields = dict(
asset_id=asset.id,
type=automation_type,
From 1c7b3d39b8da701cc27b2c5ae4b98574ac051bd7 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Wed, 12 Aug 2026 00:52:40 +0100
Subject: [PATCH 12/18] fix(data/services): hide inaccessible sensor names
Permission failures now identify an inaccessible automation dependency only by the sensor ID supplied in the request. This preserves a useful reference for the caller without confirming private sensor names across organisation boundaries.
Signed-off-by: Mohamed Belhsan Hmida
---
flexmeasures/data/services/automations.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index b89fa4363e..dddbf4f2de 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -152,7 +152,7 @@ def check_sensor_access(
exc,
"api_message",
f"You cannot set up an automation that would {action} sensor"
- f" {sensor.id} ({sensor.name}), because you cannot {action} it yourself.",
+ f" {sensor.id}, because you cannot {action} it yourself.",
)
raise
From b7979b506da69c6088f53b955615413dd69cde19 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Wed, 12 Aug 2026 00:53:49 +0100
Subject: [PATCH 13/18] test(api/v3_0): isolate automation endpoint tests
Automation endpoint tests now use function-scoped fresh database fixtures because they create, update, and delete automations and related sensors. The permission cases also assert that forbidden responses retain the submitted sensor ID without disclosing its private name.
Signed-off-by: Mohamed Belhsan Hmida
---
.../api/v3_0/tests/test_automations_api.py | 134 +++++++++---------
1 file changed, 70 insertions(+), 64 deletions(-)
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index 331b75e808..d3080431f4 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -13,9 +13,9 @@
from flexmeasures.data.models.time_series import Sensor
-@pytest.fixture(scope="module")
-def add_automations(db, add_battery_assets):
- battery = add_battery_assets["Test battery"]
+@pytest.fixture(scope="function")
+def add_automations(fresh_db, add_battery_assets_fresh_db):
+ battery = add_battery_assets_fresh_db["Test battery"]
generator = DataSource(
name="automations API test generator",
type="forecaster",
@@ -45,8 +45,8 @@ def add_automations(db, add_battery_assets):
parameters={"sensor": battery.sensors[0].id},
),
]
- db.session.add_all(automations)
- db.session.flush()
+ fresh_db.session.add_all(automations)
+ fresh_db.session.flush()
return automations
@@ -61,12 +61,12 @@ def add_automations(db, add_battery_assets):
)
def test_get_automations_auth(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
expected_status_code,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.get(
url_for("AssetAPI:get_automations", id=battery.id),
@@ -79,11 +79,11 @@ def test_get_automations_auth(
)
def test_get_automations(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.get(
url_for("AssetAPI:get_automations", id=battery.id),
@@ -110,11 +110,11 @@ def test_get_automations(
)
def test_get_automation_details(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
automation = add_automations[0]
with app.test_client() as client:
response = client.get(
@@ -141,12 +141,12 @@ def test_get_automation_details(
)
def test_get_automation_of_other_asset(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
):
"""Requesting an automation via an asset it does not belong to should return 404."""
- other_asset = add_battery_assets["Test small battery"]
+ other_asset = add_battery_assets_fresh_db["Test small battery"]
automation = add_automations[0]
with app.test_client() as client:
response = client.get(
@@ -164,11 +164,11 @@ def test_get_automation_of_other_asset(
)
def test_get_nonexistent_automation(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.get(
url_for("AssetAPI:get_automation", id=battery.id, automation_id=9999),
@@ -187,13 +187,13 @@ def test_get_nonexistent_automation(
)
def test_post_automation(
app,
- db,
- add_battery_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
requesting_user,
expected_status_code,
):
"""Only account admins (and consultants) can create automations; parameters are validated by type."""
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
@@ -209,11 +209,11 @@ def test_post_automation(
assert response.json["name"] == "Posted schedules"
assert response.json["active"] is True
assert response.json["recurrence_description"] == "At 00:00"
- automation = db.session.get(Automation, response.json["id"])
+ automation = fresh_db.session.get(Automation, response.json["id"])
assert automation.parameters == {"duration": "PT12H"}
# clean up for other tests in this module
- db.session.delete(automation)
- db.session.flush()
+ fresh_db.session.delete(automation)
+ fresh_db.session.flush()
@pytest.mark.parametrize(
@@ -221,10 +221,10 @@ def test_post_automation(
)
def test_post_automation_with_invalid_parameters(
app,
- add_battery_assets,
+ add_battery_assets_fresh_db,
requesting_user,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
@@ -244,12 +244,12 @@ def test_post_automation_with_invalid_parameters(
)
def test_post_and_patch_automation_timezone(
app,
- db,
- add_battery_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
requesting_user,
):
"""An automation's timezone can be set on creation and changed afterwards, as it can from the CLI."""
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
@@ -263,7 +263,7 @@ def test_post_and_patch_automation_timezone(
)
assert response.status_code == 201, response.json
assert response.json["timezone"] == "Asia/Seoul"
- automation = db.session.execute(
+ automation = fresh_db.session.execute(
select(Automation).filter_by(name="Seoul forecasts")
).scalar_one()
assert automation.timezone == "Asia/Seoul"
@@ -298,24 +298,24 @@ def test_post_and_patch_automation_timezone(
)
def test_post_automation_with_inaccessible_source_filtered_regressor(
app,
- db,
- add_battery_assets,
- setup_generic_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
+ setup_generic_assets_fresh_db,
requesting_user,
):
"""A regressor that filters on sources is a sensor reference, and still counts as a sensor read."""
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
someone_elses_sensor = Sensor(
name="wind speed for a filtered regressor",
- generic_asset=setup_generic_assets[
+ generic_asset=setup_generic_assets_fresh_db[
"test_wind_turbine"
], # owned by the Supplier account
event_resolution=timedelta(minutes=15),
unit="m/s",
)
- db.session.add(someone_elses_sensor)
- db.session.flush()
- data_sources_before = set(db.session.scalars(select(DataSource.id)).all())
+ fresh_db.session.add(someone_elses_sensor)
+ fresh_db.session.flush()
+ data_sources_before = set(fresh_db.session.scalars(select(DataSource.id)).all())
with app.test_client() as client:
response = client.post(
@@ -337,8 +337,9 @@ def test_post_automation_with_inaccessible_source_filtered_regressor(
)
assert response.status_code == 403
assert str(someone_elses_sensor.id) in response.json["message"]
+ assert someone_elses_sensor.name not in response.json["message"]
assert (
- db.session.execute(
+ fresh_db.session.execute(
select(Automation).filter_by(
name="Forecasts regressing on another account's sensor"
)
@@ -346,7 +347,10 @@ def test_post_automation_with_inaccessible_source_filtered_regressor(
is None
)
# a refused request also leaves behind no data source for the forecaster it would have run
- assert set(db.session.scalars(select(DataSource.id)).all()) == data_sources_before
+ assert (
+ set(fresh_db.session.scalars(select(DataSource.id)).all())
+ == data_sources_before
+ )
@pytest.mark.parametrize(
@@ -354,23 +358,23 @@ def test_post_automation_with_inaccessible_source_filtered_regressor(
)
def test_post_automation_with_inaccessible_sensor(
app,
- db,
- add_battery_assets,
- setup_generic_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
+ setup_generic_assets_fresh_db,
requesting_user,
):
"""An account admin cannot set up an automation on a sensor of another account."""
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
someone_elses_sensor = Sensor(
name="wind speed",
- generic_asset=setup_generic_assets[
+ generic_asset=setup_generic_assets_fresh_db[
"test_wind_turbine"
], # owned by the Supplier account
event_resolution=timedelta(minutes=15),
unit="m/s",
)
- db.session.add(someone_elses_sensor)
- db.session.flush()
+ fresh_db.session.add(someone_elses_sensor)
+ fresh_db.session.flush()
with app.test_client() as client:
response = client.post(
@@ -384,8 +388,9 @@ def test_post_automation_with_inaccessible_sensor(
)
assert response.status_code == 403
assert str(someone_elses_sensor.id) in response.json["message"]
+ assert someone_elses_sensor.name not in response.json["message"]
assert (
- db.session.execute(
+ fresh_db.session.execute(
select(Automation).filter_by(name="Forecasts of another account's sensor")
).scalar_one_or_none()
is None
@@ -405,8 +410,8 @@ def test_post_automation_with_inaccessible_sensor(
)
assert response.status_code == 201, response.json
# clean up for other tests in this module
- db.session.delete(db.session.get(Automation, response.json["id"]))
- db.session.flush()
+ fresh_db.session.delete(fresh_db.session.get(Automation, response.json["id"]))
+ fresh_db.session.flush()
@pytest.mark.parametrize(
@@ -414,9 +419,9 @@ def test_post_automation_with_inaccessible_sensor(
)
def test_post_schedule_automation_with_inaccessible_output_sensor(
app,
- db,
- add_battery_assets,
- setup_generic_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
+ setup_generic_assets_fresh_db,
requesting_user,
):
"""Sensors that a schedule would be recorded on are checked, wherever they are named.
@@ -424,17 +429,17 @@ def test_post_schedule_automation_with_inaccessible_output_sensor(
The aggregate power schedule is recorded on the flex-context's aggregate-consumption
sensor, so that one needs to be writable, too — not just the flex-model's own sensors.
"""
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
someone_elses_sensor = Sensor(
name="aggregate consumption",
- generic_asset=setup_generic_assets[
+ generic_asset=setup_generic_assets_fresh_db[
"test_wind_turbine"
], # owned by the Supplier account
event_resolution=timedelta(minutes=15),
unit="MW",
)
- db.session.add(someone_elses_sensor)
- db.session.flush()
+ fresh_db.session.add(someone_elses_sensor)
+ fresh_db.session.flush()
with app.test_client() as client:
response = client.post(
@@ -453,6 +458,7 @@ def test_post_schedule_automation_with_inaccessible_output_sensor(
)
assert response.status_code == 403
assert str(someone_elses_sensor.id) in response.json["message"]
+ assert someone_elses_sensor.name not in response.json["message"]
assert "record data on" in response.json["message"]
@@ -466,13 +472,13 @@ def test_post_schedule_automation_with_inaccessible_output_sensor(
)
def test_patch_automation(
app,
- db,
- add_battery_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
expected_status_code,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
automation = add_automations[0]
original_name = automation.name
with app.test_client() as client:
@@ -491,7 +497,7 @@ def test_patch_automation(
# restore for other tests in this module
automation.name = original_name
automation.active = True
- db.session.flush()
+ fresh_db.session.flush()
@pytest.mark.parametrize(
@@ -499,12 +505,12 @@ def test_patch_automation(
)
def test_delete_automation(
app,
- db,
- add_battery_assets,
+ fresh_db,
+ add_battery_assets_fresh_db,
add_automations,
requesting_user,
):
- battery = add_battery_assets["Test battery"]
+ battery = add_battery_assets_fresh_db["Test battery"]
automation = Automation(
asset_id=battery.id,
# a forecast automation is required to have a data generator holding its forecaster config
@@ -514,8 +520,8 @@ def test_delete_automation(
cronstr="0 6 * * *",
parameters={"sensor": battery.sensors[0].id},
)
- db.session.add(automation)
- db.session.flush()
+ fresh_db.session.add(automation)
+ fresh_db.session.flush()
with app.test_client() as client:
response = client.delete(
url_for(
@@ -525,7 +531,7 @@ def test_delete_automation(
),
)
assert response.status_code == 204
- assert db.session.get(Automation, automation.id) is None
+ assert fresh_db.session.get(Automation, automation.id) is None
# deleting again yields the documented 404
response = client.delete(
From 10b915540c59150d69a5907d850f16b1db8e2797 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Wed, 12 Aug 2026 00:54:42 +0100
Subject: [PATCH 14/18] feat(ui/views): provide automation timezones
The asset automations view now supplies the canonical IANA timezone choices accepted by the automation schema. Keeping the options server-side ensures the create and edit controls offer the same vocabulary that the API validates.
Signed-off-by: Mohamed Belhsan Hmida
---
flexmeasures/ui/views/assets/views.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py
index 7bb8dbd525..d58f8725b8 100644
--- a/flexmeasures/ui/views/assets/views.py
+++ b/flexmeasures/ui/views/assets/views.py
@@ -7,6 +7,7 @@
from flask_security import login_required, current_user
from webargs.flaskparser import use_kwargs
from marshmallow import ValidationError
+from pytz import all_timezones
from flexmeasures.data import db
from flexmeasures.auth.policy import check_access
@@ -256,6 +257,7 @@ def automations(self, id: str):
return render_flexmeasures_template(
"assets/asset_automations.html",
asset=asset,
+ available_timezones=all_timezones,
# managing automations requires the same principals that may delete the asset
user_can_manage_automations=user_can_delete(asset),
current_page="Automations",
From a39c4eda06033d579f5fd0d40133274d4f682af2 Mon Sep 17 00:00:00 2001
From: Mohamed Belhsan Hmida
Date: Wed, 12 Aug 2026 00:55:28 +0100
Subject: [PATCH 15/18] feat(ui): edit automation recurrence timezones
Managers can now choose an IANA timezone when creating an automation and edit its name, recurrence, timezone, and active state from the asset page. New automations default to the asset timezone, while the API remains responsible for validating every submitted value.
Signed-off-by: Mohamed Belhsan Hmida
---
.../templates/assets/asset_automations.html | 91 ++++++++++++++++++-
1 file changed, 90 insertions(+), 1 deletion(-)
diff --git a/flexmeasures/ui/templates/assets/asset_automations.html b/flexmeasures/ui/templates/assets/asset_automations.html
index 14d8c65edb..39b4a76298 100644
--- a/flexmeasures/ui/templates/assets/asset_automations.html
+++ b/flexmeasures/ui/templates/assets/asset_automations.html
@@ -31,6 +31,12 @@
+
+
@@ -54,7 +60,12 @@
New automation for {{ asset.name }}
-
In the platform timezone, e.g. "0 6 * * *" for daily at 6:00.
+
For example, "0 6 * * *" means daily at 6:00 in the selected timezone.
+
+
+
+
+
Choose the IANA timezone in which this recurrence should follow the local clock. The asset timezone is selected by default.