Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ API change log

.. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace.

v3.0-39 | September 17, 2026
""""""""""""""""""""""""""""
- ``POST /api/v3_0/assets/<id>/automations`` now names the part of the request each validation error came from. An error in the data generator's configuration is reported under ``config``, where every error used to be reported under ``parameters``, and a report automation that names no reporter is reported under ``data-generator`` rather than as a bare message. A ``scheduling`` automation now rejects ``config`` and ``data-generator`` with a ``422`` naming the field, where it used to answer ``201 Created`` and ignore both: a schedule automation's scheduler and the flex config it runs under follow from the asset and its flex context. This is not a breaking change, as the automation endpoints have not been part of a release.

v3.0-37 | September 15, 2026
""""""""""""""""""""""""""""
- Added ``POST /api/v3_0/assets/<id>/automations``, ``PATCH /api/v3_0/assets/<id>/automations/<automation_id>`` and ``DELETE /api/v3_0/assets/<id>/automations/<automation_id>`` for managing an asset's automations. They require the same permission as writing data under the asset, 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 asset's own timezone, taken from the asset's timezone attribute or one of its sensors, and to the server's ``FLEXMEASURES_TIMEZONE`` if the asset has neither. The automation endpoints also report a ``next_run``: the next scheduled run, null while the automation is inactive, and excluding any catch-up run still pending. Both ``next_run`` and ``cursor`` are now reported as clock times in the automation's own timezone, where ``cursor`` was previously reported in UTC, since a recurrence is read in that timezone. The automation endpoints' last two snake_case fields, ``job_stats`` and ``redis_connection_err``, are now ``job-stats`` and ``redis-connection-err``, like the rest. ``GET /api/v3_0/assets/<id>/jobs`` renames its ``redis_connection_err`` to ``redis-connection-err`` as well, so that one field is not spelled two ways; that field shipped in v1.0.0, and is read by FlexMeasures' own UI rather than by the FlexMeasures client.
Expand Down
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Automations arrived over several pull requests. This is what each of them contri
* Reports as well as forecasts and schedules: a report automation stores report parameters, with its reporter and the reporter's configuration on a data source, and reports on a period resolved afresh on each run, either from ``start-offset`` and ``end-offset`` applied to the run time in the automation's timezone, or since the last successful report ended, while a fixed ``start`` or ``end`` is refused; a report job records only on the sensors the automation was checked against [see `PR #2297 <https://www.github.com/FlexMeasures/flexmeasures/pull/2297>`_]
* Every automation times its runs the same way: a fixed ``start``, ``end`` or ``prior`` in its parameters is refused, as every run would share that moment, and two of ``start-offset``, ``end-offset`` and ``duration`` describe the period each run covers instead, with the offsets applied to the time the run was due on the automation's own clock, so that, for instance, a schedule automation can plan the whole of the next day [see `PR #2551 <https://www.github.com/FlexMeasures/flexmeasures/pull/2551>`_]
* Look up automations from the command line with ``flexmeasures show automations``, which lists them all (inactive ones included) with the IDs that the edit, delete and run commands expect, and, with ``--id``, shows a single automation's recurrence, cursor, parameters and the sensors it reads from and writes to [see `PR #2533 <https://www.github.com/FlexMeasures/flexmeasures/pull/2533>`_]
* An automation that is refused now names the part of the request at fault: a mistake in a data generator's configuration is reported against ``config`` rather than against ``parameters``, and a report automation that names no reporter is reported against ``data-generator``. A schedule automation now refuses a ``config`` or a ``data-generator`` by name, where it used to accept and silently ignore both, since its scheduler and the flex config it runs under follow from the asset and its flex context [see `PR #2563 <https://www.github.com/FlexMeasures/flexmeasures/pull/2563>`_]


v1.0.1 | September 9, 2026
Expand Down
4 changes: 3 additions & 1 deletion flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1747,7 +1747,9 @@ def post_automation(self, automation_data: dict, id: int, asset: GenericAsset):
asset, origin="API", check_permissions=True, **automation_data
)
except ValidationError as e:
return unprocessable_entity({"parameters": e.messages})
# The service names the part of the request each error came from,
# so that an error in the config is not reported against the parameters.
return unprocessable_entity(e.messages)
except AutomationSensorsUnknown as e:
return unprocessable_entity(str(e))
except ValueError as e:
Expand Down
252 changes: 251 additions & 1 deletion flexmeasures/api/v3_0/tests/test_automations_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone

import pytest
from flask import url_for
from sqlalchemy import select
from sqlalchemy import func, select

from flexmeasures.data.models.automations import Automation
from flexmeasures.data.models.data_sources import DataSource
from flexmeasures.data.models.time_series import Sensor


def _with_sensor(parameters: dict, sensor_id: int) -> dict:
"""Fill in the sensor id that a parametrised payload leaves as "SENSOR".

The id only exists once the fixtures have run, which is after the parameters are written.
"""
return json.loads(json.dumps(parameters).replace('"SENSOR"', str(sensor_id)))


@pytest.fixture(scope="function")
def add_automations(fresh_db, add_battery_assets_fresh_db):
battery = add_battery_assets_fresh_db["Test battery"]
Expand Down Expand Up @@ -930,3 +939,244 @@ def test_a_forecast_automation_names_the_data_generator_it_runs(

fresh_db.session.delete(automation)
fresh_db.session.flush()


@pytest.mark.parametrize(
"automation_type, data_generator, config, parameters",
[
(
"forecasting",
"TrainPredictPipeline",
{"not-a-config-field": 1},
{"sensor": "SENSOR"},
),
(
"reporting",
"PandasReporter",
{
"required_input": [{"name": "flow"}],
"required_output": [{"name": "flow"}],
"transformations": [],
"not-a-config-field": 1,
},
{
"input": [{"name": "flow", "sensor": "SENSOR"}],
"output": [{"name": "flow", "sensor": "SENSOR"}],
},
),
],
)
@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_post_automation_reports_a_config_error_against_the_config(
app,
fresh_db,
add_battery_assets_fresh_db,
requesting_user,
automation_type,
data_generator,
config,
parameters,
):
"""A fault in the data generator's config is reported against `config`, not against `parameters`.

Both are validated by schemas of the data generator's choosing, so naming the wrong one
sends the caller looking for a mistake in a part of the request that is fine.
"""
battery = add_battery_assets_fresh_db["Test battery"]
sensor_id = battery.sensors[0].id
parameters = _with_sensor(parameters, sensor_id)

with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
json={
"name": "Bad config",
"cron": "0 6 * * *",
"type": automation_type,
"data-generator": data_generator,
"config": config,
"parameters": parameters,
},
)

assert response.status_code == 422, response.json
messages = response.json["message"]["json"]
assert "not-a-config-field" in str(messages["config"])
assert "parameters" not in messages


@pytest.mark.parametrize(
"automation_type, data_generator, config, parameters",
[
(
"forecasting",
"TrainPredictPipeline",
{},
{"sensor": "SENSOR", "not-a-parameter": 1},
),
(
"reporting",
"PandasReporter",
{
"required_input": [{"name": "flow"}],
"required_output": [{"name": "flow"}],
"transformations": [],
},
{
"input": [{"name": "flow", "sensor": "SENSOR"}],
"output": [{"name": "flow", "sensor": "SENSOR"}],
"not-a-parameter": 1,
},
),
("scheduling", None, {}, {"duration": "PT12H", "not-a-parameter": 1}),
],
)
@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_post_automation_reports_a_parameter_error_against_the_parameters(
app,
fresh_db,
add_battery_assets_fresh_db,
requesting_user,
automation_type,
data_generator,
config,
parameters,
):
"""A fault in the parameters is reported against `parameters`, for every automation type."""
battery = add_battery_assets_fresh_db["Test battery"]
parameters = _with_sensor(parameters, battery.sensors[0].id)
payload = {
"name": "Bad parameters",
"cron": "0 6 * * *",
"type": automation_type,
"parameters": parameters,
}
if data_generator is not None:
payload["data-generator"] = data_generator
payload["config"] = config

with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id), json=payload
)

assert response.status_code == 422, response.json
messages = response.json["message"]["json"]
assert "not-a-parameter" in str(messages["parameters"])
assert "config" not in messages


@pytest.mark.parametrize(
"field, value",
[
("config", {"model": "CustomLGBM"}),
("data-generator", "TrainPredictPipeline"),
],
)
@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_post_schedule_automation_rejects_a_data_generator_and_its_config(
app, fresh_db, add_battery_assets_fresh_db, requesting_user, field, value
):
"""A schedule automation resolves its own scheduler and flex config from the asset.

Taking either field here would record a choice that nothing goes on to read,
so each is refused by name rather than silently ignored.
"""
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),
json={
"name": "Schedules with an unusable field",
"cron": "0 6 * * *",
"type": "scheduling",
"parameters": {"duration": "PT12H"},
field: value,
},
)

assert response.status_code == 422, response.json
assert field in response.json["message"]["json"]
assert (
fresh_db.session.execute(
select(Automation).filter_by(name="Schedules with an unusable field")
).scalar_one_or_none()
is None
)


@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_post_report_automation_without_a_reporter_names_the_field_to_fill_in(
app, fresh_db, add_battery_assets_fresh_db, requesting_user
):
"""A report automation has to name its reporter, and the error says which field is missing."""
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),
json={
"name": "Reporter-less report",
"cron": "0 1 * * *",
"type": "reporting",
"parameters": {"input": [{"sensor": battery.sensors[0].id}]},
},
)

assert response.status_code == 422, response.json
assert "A reporter is required" in str(
response.json["message"]["json"]["data-generator"]
)


@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_a_refused_automation_leaves_nothing_behind(
app, fresh_db, add_battery_assets_fresh_db, requesting_user
):
"""A rejected request records neither the automation, nor a data source for its generator, nor an audit log entry."""
from flexmeasures.data.models.audit_log import AssetAuditLog

battery = add_battery_assets_fresh_db["Test battery"]
sensor_id = battery.sensors[0].id
before = {
model: fresh_db.session.scalar(select(func.count()).select_from(model))
for model in (Automation, DataSource, AssetAuditLog)
}
refused = [
{
"type": "forecasting",
"config": {"not-a-config-field": 1},
"parameters": {"sensor": sensor_id},
},
{
"type": "forecasting",
"parameters": {"sensor": sensor_id, "not-a-parameter": 1},
},
{
"type": "scheduling",
"data-generator": "TrainPredictPipeline",
"parameters": {"duration": "PT12H"},
},
]
with app.test_client() as client:
for index, payload in enumerate(refused):
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
json={"name": f"Refused {index}", "cron": "0 6 * * *", **payload},
)
assert response.status_code == 422, response.json

after = {
model: fresh_db.session.scalar(select(func.count()).select_from(model))
for model in (Automation, DataSource, AssetAuditLog)
}
assert after == before
3 changes: 2 additions & 1 deletion flexmeasures/cli/data_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -1966,8 +1966,9 @@ def add_automation(
origin="CLI",
)
except ValidationError as e:
# The messages name the part of the request at fault, which is not always the parameters.
click.secho(
f"Invalid {Automation.RESULT_NOUNS[automation_type]} parameters: {e.messages}",
f"Invalid {Automation.RESULT_NOUNS[automation_type]} automation: {e.messages}",
**MsgStyle.ERROR,
)
raise click.Abort()
Expand Down
49 changes: 46 additions & 3 deletions flexmeasures/cli/tests/test_automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,11 @@ def test_add_schedule_automation(app, fresh_db, setup_dummy_data, tmp_path):
],
) # fmt: skip
assert result.exit_code != 0
assert "Invalid schedule parameters" in result.output
# The error names the part of the request at fault, which for a schedule automation is always the parameters.
assert "Invalid schedule automation" in result.output
assert (
"{'parameters': {'not-a-trigger-field': ['Unknown field.']}}" in result.output
)

# minimal valid parameters (flex config can live on the asset)
parameters_file.write_text('duration: "PT12H"\n')
Expand Down Expand Up @@ -1029,7 +1033,8 @@ def test_add_schedule_automation_rejects_unsupported_durations(
)

assert result.exit_code != 0
assert "Invalid schedule parameters" in result.output
assert "Invalid schedule automation" in result.output
assert "'parameters'" in result.output


def test_add_schedule_automation_rejects_forecast_config(
Expand Down Expand Up @@ -1103,7 +1108,45 @@ def test_add_forecast_automation_still_requires_sensor(app, fresh_db, setup_dumm
)

assert result.exit_code != 0
assert "Invalid forecast parameters" in result.output
assert "Invalid forecast automation" in result.output
assert (
"{'parameters': {'sensor': ['Missing data for required field.']}}"
in result.output
)


def test_add_forecast_automation_reports_a_config_error_against_the_config(
app, fresh_db, setup_dummy_data, tmp_path
):
"""A fault in the forecaster's config is reported against the config, rather than against the parameters.

Both are validated by schemas of the data generator's choosing, so naming the wrong one
sends the user looking for a mistake in a part of the command that is fine.
"""
from flexmeasures.cli.data_add import add_automation

config_file = tmp_path / "config.yml"
config_file.write_text("not-a-config-field: 1\n")
result = app.test_cli_runner().invoke(
add_automation,
[
"--asset", "1",
"--name", "Bad forecaster config",
"--cron", "0 6 * * *",
"--sensor", str(setup_dummy_data[0]),
"--config", str(config_file),
],
) # fmt: skip

assert result.exit_code != 0
assert "Invalid forecast automation" in result.output
assert "{'config': {'not-a-config-field': ['Unknown field.']}}" in result.output
assert (
fresh_db.session.execute(
select(Automation).filter_by(name="Bad forecaster config")
).scalar_one_or_none()
is None
)


@pytest.mark.parametrize("is_dst", (True, False))
Expand Down
Loading
Loading