From f6e1e10065954f553a2265201be2e49905225a38 Mon Sep 17 00:00:00 2001
From: "F.N. Claessen"
Date: Sat, 11 Jul 2026 18:44:10 +0200
Subject: [PATCH 1/2] feat: CRUD for automations via API and UI
- New endpoints on assets: POST /automations (create, validating parameters
by automation type), PATCH /automations/ (name, cron string, activation
status) and DELETE /automations/. Managing automations requires the
same principals that may delete the asset (account admins and consultants).
- The UI automations page gets a 'New automation' modal and per-row
(de)activate and delete actions, shown to users with management rights.
- Creation, update and deletion logic (incl. audit log records) moved into
the automations service, shared by the CLI commands and the API endpoints.
Part of FlexMeasures/flexmeasures#2288
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01Rbix8k1JfeUWNXEmHEZVpX
---
documentation/features/forecasting.rst | 2 +
flexmeasures/api/v3_0/__init__.py | 6 +
flexmeasures/api/v3_0/assets.py | 237 ++++++++++++++++-
.../api/v3_0/tests/test_automations_api.py | 143 +++++++++++
flexmeasures/cli/data_add.py | 84 ++----
flexmeasures/cli/data_delete.py | 10 +-
flexmeasures/cli/data_edit.py | 18 +-
flexmeasures/data/schemas/automations.py | 37 ++-
flexmeasures/data/services/automations.py | 132 ++++++++++
flexmeasures/ui/static/openapi-specs.json | 241 ++++++++++++++++++
.../templates/assets/asset_automations.html | 130 +++++++++-
flexmeasures/ui/views/assets/views.py | 2 +
12 files changed, 959 insertions(+), 83 deletions(-)
diff --git a/documentation/features/forecasting.rst b/documentation/features/forecasting.rst
index f6c5d57113..d492488158 100644
--- a/documentation/features/forecasting.rst
+++ b/documentation/features/forecasting.rst
@@ -204,5 +204,7 @@ Each due automation then queues its forecasting jobs.
The jobs record how they were created, which is shown on the asset's status page (UI), where recent jobs are listed.
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)`).
Schedules can be automated in the same way — see :ref:`automating_schedules`.
diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py
index 062a59a98f..35fda32151 100644
--- a/flexmeasures/api/v3_0/__init__.py
+++ b/flexmeasures/api/v3_0/__init__.py
@@ -39,6 +39,10 @@
DefaultAssetViewJSONSchema,
)
from flexmeasures.data.schemas.annotations import AnnotationSchema
+from flexmeasures.data.schemas.automations import (
+ AutomationCreationSchema,
+ AutomationUpdateSchema,
+)
from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema
from flexmeasures.data.schemas.sensors import QuantitySchema, TimeSeriesSchema
from flexmeasures.data.schemas.account import AccountSchema
@@ -157,6 +161,8 @@ def create_openapi_specs(app: Flask):
("AssetAPIQuerySchema", AssetAPIQuerySchema),
("AssetSchema", AssetSchema),
("AnnotationSchema", AnnotationSchema),
+ ("AutomationCreationSchema", AutomationCreationSchema),
+ ("AutomationUpdateSchema", AutomationUpdateSchema),
("CopyAssetSchema", CopyAssetSchema),
("DefaultAssetViewJSONSchema", DefaultAssetViewJSONSchema),
("AccountSchema", AccountSchema(partial=True)),
diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py
index 9f1ef8656c..f76153d48e 100644
--- a/flexmeasures/api/v3_0/assets.py
+++ b/flexmeasures/api/v3_0/assets.py
@@ -43,10 +43,17 @@
from flexmeasures.data.models.automations import Automation
from flexmeasures.data.models.user import Account
from flexmeasures.data.models.audit_log import AssetAuditLog
-from flexmeasures.data.schemas.automations import AutomationSchema
+from flexmeasures.data.schemas.automations import (
+ AutomationCreationSchema,
+ AutomationSchema,
+ AutomationUpdateSchema,
+)
from flexmeasures.data.services.automations import (
+ create_automation,
+ delete_automation as remove_automation,
describe_cronstr,
get_automation_job_stats,
+ update_automation,
)
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.queries.generic_assets import (
@@ -1299,6 +1306,234 @@ def get_automation(self, id: int, automation_id: int, asset: GenericAsset):
automation_data["redis_connection_err"] = redis_connection_err
return automation_data, 200
+ @route("//automations", methods=["POST"])
+ @use_kwargs(
+ {"asset": AssetIdField(data_key="id")},
+ location="path",
+ )
+ # Managing automations requires the same principals that may delete the asset
+ # (i.e. account admins and consultants), matching the Automation ACL.
+ @permission_required_for_context("delete", ctx_arg_name="asset")
+ @as_json
+ def post_automation(self, id: int, asset: GenericAsset):
+ """
+ .. :quickref: Assets; Create an automation on an asset.
+
+ ---
+ post:
+ summary: Create an automation on an asset.
+ description: |
+ Create a recurring task (computing forecasts or schedules) on the asset.
+ The parameters are validated by the schema matching the automation type:
+ forecast parameters for type `forecasts`, or a schedule trigger message
+ (without the asset id) for type `schedules`.
+ Requires account admin or consultant rights.
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - in: path
+ name: id
+ required: true
+ description: ID of the asset to create the automation on.
+ schema:
+ type: integer
+ requestBody:
+ content:
+ application/json:
+ schema: AutomationCreationSchema
+ examples:
+ daily_forecasts:
+ summary: Daily forecasts of sensor 2092
+ value:
+ name: Day-ahead PV forecasts
+ cronstr: "0 6 * * *"
+ type: forecasts
+ parameters:
+ sensor: 2092
+ responses:
+ 201:
+ description: CREATED
+ 400:
+ description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+ 401:
+ description: UNAUTHORIZED
+ 403:
+ description: INVALID_SENDER
+ 422:
+ description: UNPROCESSABLE_ENTITY
+ tags:
+ - Assets
+ """
+ body = request.get_json(silent=True)
+ if not body:
+ return unprocessable_entity("No JSON data provided.")
+ try:
+ automation_data = AutomationCreationSchema().load(body)
+ except ValidationError as e:
+ return unprocessable_entity(e.messages)
+ try:
+ automation, warnings = create_automation(
+ asset=asset,
+ name=automation_data["name"],
+ cronstr=automation_data["cronstr"],
+ automation_type=automation_data["type"],
+ active=automation_data["active"],
+ parameters=automation_data["parameters"],
+ forecaster_class=automation_data["forecaster"],
+ config=automation_data["config"],
+ origin="API",
+ )
+ except ValidationError as e:
+ return unprocessable_entity({"parameters": e.messages})
+ except ValueError as e:
+ return unprocessable_entity(str(e))
+ db.session.commit()
+ response = automation_schema.dump(automation)
+ response["recurrence_description"] = describe_cronstr(automation.cronstr)
+ response["warnings"] = warnings
+ return response, 201
+
+ @route("//automations/", methods=["PATCH"])
+ @use_kwargs(
+ {
+ "asset": AssetIdField(data_key="id"),
+ "automation_id": fields.Int(),
+ },
+ location="path",
+ )
+ # Managing automations requires the same principals that may delete the asset
+ # (i.e. account admins and consultants), matching the Automation ACL.
+ @permission_required_for_context("delete", ctx_arg_name="asset")
+ @as_json
+ def patch_automation(self, id: int, automation_id: int, asset: GenericAsset):
+ """
+ .. :quickref: Assets; Update an automation's name, cron string or activation status.
+
+ ---
+ patch:
+ summary: Update an automation's name, cron string or activation status.
+ description: |
+ Any subset of the fields `name`, `cronstr` and `active` can be sent.
+ Other automation fields cannot be updated; instead, create a new automation.
+ Requires account admin or consultant rights.
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - in: path
+ name: id
+ required: true
+ description: ID of the asset.
+ schema:
+ type: integer
+ - in: path
+ name: automation_id
+ required: true
+ description: ID of the automation.
+ schema:
+ type: integer
+ requestBody:
+ content:
+ application/json:
+ schema: AutomationUpdateSchema
+ examples:
+ deactivate:
+ summary: Deactivate the automation
+ value:
+ active: false
+ responses:
+ 200:
+ description: PROCESSED
+ 400:
+ description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+ 401:
+ description: UNAUTHORIZED
+ 403:
+ description: INVALID_SENDER
+ 404:
+ description: NOT_FOUND
+ 422:
+ description: UNPROCESSABLE_ENTITY
+ tags:
+ - Assets
+ """
+ automation = db.session.get(Automation, automation_id)
+ if automation is None or automation.asset_id != asset.id:
+ return {
+ "message": f"Asset {asset.id} has no automation with id {automation_id}."
+ }, 404
+ body = request.get_json(silent=True)
+ if not body:
+ return unprocessable_entity("No JSON data provided.")
+ try:
+ automation_data = AutomationUpdateSchema().load(body)
+ except ValidationError as e:
+ return unprocessable_entity(e.messages)
+ update_automation(automation, origin="API", **automation_data)
+ db.session.commit()
+ response = automation_schema.dump(automation)
+ response["recurrence_description"] = describe_cronstr(automation.cronstr)
+ return response, 200
+
+ @route("//automations/", methods=["DELETE"])
+ @use_kwargs(
+ {
+ "asset": AssetIdField(data_key="id"),
+ "automation_id": fields.Int(),
+ },
+ location="path",
+ )
+ # Managing automations requires the same principals that may delete the asset
+ # (i.e. account admins and consultants), matching the Automation ACL.
+ @permission_required_for_context("delete", ctx_arg_name="asset")
+ @as_json
+ def delete_automation(self, id: int, automation_id: int, asset: GenericAsset):
+ """
+ .. :quickref: Assets; Delete an automation.
+
+ ---
+ delete:
+ summary: Delete an automation.
+ description: |
+ Delete the automation. Any jobs it already queued are unaffected.
+ Requires account admin or consultant rights.
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - in: path
+ name: id
+ required: true
+ description: ID of the asset.
+ schema:
+ type: integer
+ - in: path
+ name: automation_id
+ required: true
+ description: ID of the automation.
+ schema:
+ type: integer
+ responses:
+ 204:
+ description: DELETED
+ 400:
+ description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+ 401:
+ description: UNAUTHORIZED
+ 403:
+ description: INVALID_SENDER
+ 404:
+ description: NOT_FOUND
+ tags:
+ - Assets
+ """
+ automation = db.session.get(Automation, automation_id)
+ if automation is None or automation.asset_id != asset.id:
+ return {
+ "message": f"Asset {asset.id} has no automation with id {automation_id}."
+ }, 404
+ remove_automation(automation, origin="API")
+ db.session.commit()
+ return {}, 204
+
@route("//jobs", methods=["GET"])
@use_kwargs(
{"asset": AssetIdField(data_key="id")},
diff --git a/flexmeasures/api/v3_0/tests/test_automations_api.py b/flexmeasures/api/v3_0/tests/test_automations_api.py
index 8c9673e4dd..f1481c4a64 100644
--- a/flexmeasures/api/v3_0/tests/test_automations_api.py
+++ b/flexmeasures/api/v3_0/tests/test_automations_api.py
@@ -150,3 +150,146 @@ def test_get_nonexistent_automation(
url_for("AssetAPI:get_automation", id=battery.id, automation_id=9999),
)
assert response.status_code == 404
+
+
+@pytest.mark.parametrize(
+ "requesting_user, expected_status_code",
+ [
+ ("test_prosumer_user@seita.nl", 403), # plain account member
+ ("test_prosumer_user_2@seita.nl", 201), # account admin
+ ("test_dummy_user_3@seita.nl", 403), # different account
+ ],
+ indirect=["requesting_user"],
+)
+def test_post_automation(
+ app,
+ db,
+ add_battery_assets,
+ requesting_user,
+ expected_status_code,
+):
+ """Only account admins (and consultants) can create automations; parameters are validated by type."""
+ 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": "Posted schedules",
+ "cronstr": "0 0 * * *",
+ "type": "schedules",
+ "parameters": {"duration": "PT12H"},
+ },
+ )
+ assert response.status_code == expected_status_code
+ if expected_status_code == 201:
+ 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"])
+ assert automation.parameters == {"duration": "PT12H"}
+ # clean up for other tests in this module
+ db.session.delete(automation)
+ db.session.flush()
+
+
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_post_automation_with_invalid_parameters(
+ app,
+ add_battery_assets,
+ requesting_user,
+):
+ 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": "Bad forecasts",
+ "cronstr": "0 6 * * *",
+ "type": "forecasts",
+ "parameters": {}, # missing required sensor
+ },
+ )
+ assert response.status_code == 422
+ assert "sensor" in str(response.json)
+
+
+@pytest.mark.parametrize(
+ "requesting_user, expected_status_code",
+ [
+ ("test_prosumer_user@seita.nl", 403), # plain account member
+ ("test_prosumer_user_2@seita.nl", 200), # account admin
+ ],
+ indirect=["requesting_user"],
+)
+def test_patch_automation(
+ app,
+ db,
+ add_battery_assets,
+ add_automations,
+ requesting_user,
+ expected_status_code,
+):
+ battery = add_battery_assets["Test battery"]
+ automation = add_automations[0]
+ original_name = automation.name
+ with app.test_client() as client:
+ response = client.patch(
+ url_for(
+ "AssetAPI:patch_automation",
+ id=battery.id,
+ automation_id=automation.id,
+ ),
+ json={"name": "Renamed via API", "active": False},
+ )
+ assert response.status_code == expected_status_code
+ if expected_status_code == 200:
+ assert response.json["name"] == "Renamed via API"
+ assert response.json["active"] is False
+ # restore for other tests in this module
+ automation.name = original_name
+ automation.active = True
+ db.session.flush()
+
+
+@pytest.mark.parametrize(
+ "requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
+)
+def test_delete_automation(
+ app,
+ db,
+ add_battery_assets,
+ add_automations,
+ requesting_user,
+):
+ battery = add_battery_assets["Test battery"]
+ automation = Automation(
+ asset_id=battery.id,
+ type="forecasts",
+ name="To be deleted",
+ cronstr="0 6 * * *",
+ parameters={"sensor": battery.sensors[0].id},
+ )
+ db.session.add(automation)
+ db.session.flush()
+ with app.test_client() as client:
+ response = client.delete(
+ url_for(
+ "AssetAPI:delete_automation",
+ id=battery.id,
+ automation_id=automation.id,
+ ),
+ )
+ assert response.status_code == 204
+ assert db.session.get(Automation, automation.id) is None
+
+ # deleting again yields the documented 404
+ response = client.delete(
+ url_for(
+ "AssetAPI:delete_automation",
+ id=battery.id,
+ automation_id=automation.id,
+ ),
+ )
+ assert response.status_code == 404
diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py
index 1de8452f7f..9d4de8e340 100755
--- a/flexmeasures/cli/data_add.py
+++ b/flexmeasures/cli/data_add.py
@@ -45,7 +45,7 @@
populate_initial_structure,
add_default_asset_types,
)
-from flexmeasures.data.services.automations import prepare_schedule_trigger_message
+from flexmeasures.data.services.automations import create_automation
from flexmeasures.data.services.data_sources import (
get_or_create_source,
get_data_generator,
@@ -1311,67 +1311,31 @@ def add_automation(
kwargs, config_file, parameters_file
)
- # Validate the parameters using the forecast parameters schema (we store them serialized)
- generator_id = None
- if automation_type == "forecasts":
- try:
- deserialized_parameters = ForecasterParametersSchema().load(parameters)
- except ValidationError as e:
- click.secho(f"Invalid forecast parameters: {e.messages}", **MsgStyle.ERROR)
- raise click.Abort()
- sensor = deserialized_parameters.get("sensor")
- if isinstance(sensor, Sensor) and sensor.generic_asset_id != asset.id:
- click.secho(
- f"Warning: the sensor to forecast ({sensor.id}) does not belong to asset {asset.id}.",
- **MsgStyle.WARN,
- )
-
- forecaster = get_data_generator(
- source=source,
- model=forecaster_class,
+ # The service validates the parameters by automation type (we store them serialized)
+ try:
+ automation, warnings = create_automation(
+ asset=asset,
+ name=name,
+ cronstr=cronstr,
+ automation_type=automation_type,
+ active=not inactive,
+ parameters=parameters,
+ forecaster_class=forecaster_class,
config=config,
- save_config=True,
- data_generator_type=Forecaster,
+ source=source,
+ origin="CLI",
)
- if forecaster is None:
- click.secho(
- f"Could not set up forecaster '{forecaster_class}'.", **MsgStyle.ERROR
- )
- raise click.Abort()
- generator = (
- forecaster.data_source
- ) # looks up or creates the data source storing the forecaster config
- db.session.flush()
- generator_id = generator.id
- else: # schedules
- try:
- AssetTriggerSchema().load(
- prepare_schedule_trigger_message(parameters, asset.id)
- )
- except ValidationError as e:
- click.secho(f"Invalid schedule parameters: {e.messages}", **MsgStyle.ERROR)
- raise click.Abort()
- if "start" in parameters:
- click.secho(
- "Warning: the schedule 'start' is fixed, so each run will compute the same period."
- " Omit 'start' to schedule from the run time instead.",
- **MsgStyle.WARN,
- )
-
- automation = Automation(
- asset_id=asset.id,
- type=automation_type,
- name=name,
- cronstr=cronstr,
- active=not inactive,
- generator_id=generator_id,
- parameters=parameters,
- )
- db.session.add(automation)
- db.session.flush()
- AssetAuditLog.add_record(
- asset, f"Created automation '{name}' ({automation.id}) via CLI."
- )
+ except ValidationError as e:
+ click.secho(
+ f"Invalid {automation_type[:-1]} parameters: {e.messages}",
+ **MsgStyle.ERROR,
+ )
+ raise click.Abort()
+ except ValueError as e:
+ click.secho(str(e), **MsgStyle.ERROR)
+ raise click.Abort()
+ for warning in warnings:
+ click.secho(f"Warning: {warning}", **MsgStyle.WARN)
db.session.commit()
click.secho(
f"Successfully created {'inactive ' if inactive else ''}automation '{name}' (ID: {automation.id})"
diff --git a/flexmeasures/cli/data_delete.py b/flexmeasures/cli/data_delete.py
index 6830650fbd..b4469c934b 100644
--- a/flexmeasures/cli/data_delete.py
+++ b/flexmeasures/cli/data_delete.py
@@ -17,10 +17,12 @@
from flexmeasures import Source
from flexmeasures.data import db
from flexmeasures.data.models.user import Account, AccountRole, RolesAccounts, User
-from flexmeasures.data.models.audit_log import AssetAuditLog
from flexmeasures.data.models.automations import Automation
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.schemas.automations import AutomationIdField
+from flexmeasures.data.services.automations import (
+ delete_automation as remove_automation,
+)
from flexmeasures.data.models.time_series import Sensor, TimedBelief
from flexmeasures.data.schemas import (
AccountIdField,
@@ -294,11 +296,7 @@ def delete_automation(automation: Automation, force: bool):
if not force:
prompt = f"Delete automation '{automation.name}' (ID: {automation.id}) of asset '{automation.asset.name}'?"
click.confirm(prompt, abort=True)
- AssetAuditLog.add_record(
- automation.asset,
- f"Deleted automation '{automation.name}' ({automation.id}) via CLI.",
- )
- db.session.delete(automation)
+ remove_automation(automation, origin="CLI")
db.session.commit()
click.secho(
f"Successfully deleted automation '{automation.name}' (ID: {automation.id}).",
diff --git a/flexmeasures/cli/data_edit.py b/flexmeasures/cli/data_edit.py
index 1e7b97c4f7..2616111ae6 100644
--- a/flexmeasures/cli/data_edit.py
+++ b/flexmeasures/cli/data_edit.py
@@ -22,6 +22,7 @@
from flexmeasures.data.models.automations import Automation
from flexmeasures.data.models.audit_log import AssetAuditLog, AuditLog
from flexmeasures.data.schemas.automations import AutomationIdField, CronField
+from flexmeasures.data.services.automations import update_automation
from flexmeasures.data.models.time_series import TimedBelief
from flexmeasures.data.utils import save_to_db
from flexmeasures.cli.utils import (
@@ -91,23 +92,12 @@ def edit_automation(
active: bool | None = None,
):
"""Edit the name, recurrence (cron string) or activation status of an automation."""
- changes = []
- if name is not None and name != automation.name:
- changes.append(f"name: '{automation.name}' → '{name}'")
- automation.name = name
- if cronstr is not None and cronstr != automation.cronstr:
- changes.append(f"cron string: '{automation.cronstr}' → '{cronstr}'")
- automation.cronstr = cronstr
- if active is not None and active != automation.active:
- changes.append("activated" if active else "deactivated")
- automation.active = active
+ changes = update_automation(
+ automation, name=name, cronstr=cronstr, active=active, origin="CLI"
+ )
if not changes:
click.secho("Nothing to change.", **MsgStyle.WARN)
return
- AssetAuditLog.add_record(
- automation.asset,
- f"Updated automation '{automation.name}' ({automation.id}): {'; '.join(changes)}. Via CLI.",
- )
db.session.commit()
click.secho(
f"Successfully updated automation '{automation.name}' (ID: {automation.id}): {'; '.join(changes)}.",
diff --git a/flexmeasures/data/schemas/automations.py b/flexmeasures/data/schemas/automations.py
index 879dfa80d2..e12a49a139 100644
--- a/flexmeasures/data/schemas/automations.py
+++ b/flexmeasures/data/schemas/automations.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from croniter import croniter
-from marshmallow import fields, validates, ValidationError
+from marshmallow import fields, validate, validates, Schema, ValidationError
from flexmeasures.data import ma, db
from flexmeasures.data.models.automations import Automation
@@ -39,6 +39,41 @@ def _serialize(self, automation, attr, data, **kwargs):
return automation.id
+class AutomationCreationSchema(Schema):
+ """Request schema for creating an automation (the asset comes from the URL path).
+
+ The parameters are validated separately, by the schema matching the automation type.
+ """
+
+ type = fields.Str(
+ load_default="forecasts",
+ validate=validate.OneOf(Automation.SUPPORTED_TYPES),
+ )
+ name = fields.Str(required=True, validate=validate.Length(min=1, max=80))
+ cronstr = CronField(required=True)
+ active = fields.Bool(load_default=True)
+ parameters = fields.Dict(keys=fields.Str(), load_default=dict)
+ forecaster = fields.Str(
+ load_default="TrainPredictPipeline",
+ metadata={"description": "Forecaster class (only used for type 'forecasts')."},
+ )
+ config = fields.Dict(
+ keys=fields.Str(),
+ load_default=dict,
+ metadata={
+ "description": "Forecaster configuration (only used for type 'forecasts')."
+ },
+ )
+
+
+class AutomationUpdateSchema(Schema):
+ """Request schema for updating an automation's name, cron string and/or activation status."""
+
+ name = fields.Str(validate=validate.Length(min=1, max=80))
+ cronstr = CronField()
+ active = fields.Bool()
+
+
class AutomationSchema(ma.SQLAlchemySchema):
"""Automation schema, with validations."""
diff --git a/flexmeasures/data/services/automations.py b/flexmeasures/data/services/automations.py
index af9ea6db55..c74f20f7c9 100644
--- a/flexmeasures/data/services/automations.py
+++ b/flexmeasures/data/services/automations.py
@@ -119,6 +119,138 @@ def get_automation_job_stats(automation: Automation) -> dict[str, int]:
return counts
+def create_automation(
+ asset,
+ name: str,
+ cronstr: str,
+ automation_type: str = "forecasts",
+ active: bool = True,
+ parameters: dict | None = None,
+ forecaster_class: str = "TrainPredictPipeline",
+ config: dict | None = None,
+ source=None,
+ origin: str = "API",
+) -> 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.
+
+ :raises marshmallow.ValidationError: if the parameters are invalid.
+ :raises ValueError: if the forecaster cannot be set up.
+ :returns: the automation and a list of warnings.
+ """
+ from marshmallow import ValidationError
+
+ from flexmeasures.data.models.audit_log import AssetAuditLog
+ from flexmeasures.data.models.time_series import Sensor
+
+ parameters = parameters or {}
+ warnings: list[str] = []
+ generator_id = None
+ if automation_type == "forecasts":
+ from flexmeasures.data.schemas.forecasting.pipeline import (
+ ForecasterParametersSchema,
+ )
+ from flexmeasures.data.services.data_sources import get_data_generator
+
+ deserialized_parameters = ForecasterParametersSchema().load(parameters)
+ sensor = deserialized_parameters.get("sensor")
+ if isinstance(sensor, Sensor) and sensor.generic_asset_id != asset.id:
+ warnings.append(
+ f"The sensor to forecast ({sensor.id}) does not belong to asset {asset.id}."
+ )
+ forecaster = get_data_generator(
+ source=source,
+ model=forecaster_class,
+ config=config or {},
+ save_config=True,
+ data_generator_type=Forecaster,
+ )
+ 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
+ elif automation_type == "schedules":
+ from flexmeasures.data.schemas.scheduling import AssetTriggerSchema
+
+ AssetTriggerSchema().load(
+ prepare_schedule_trigger_message(parameters, asset.id)
+ )
+ if "start" in parameters:
+ warnings.append(
+ "The schedule 'start' is fixed, so each run will compute the same period."
+ " Omit 'start' to schedule from the run time instead."
+ )
+ else:
+ raise ValidationError(
+ f"Automation type '{automation_type}' is not supported (supported types: {Automation.SUPPORTED_TYPES})."
+ )
+
+ automation = Automation(
+ asset_id=asset.id,
+ type=automation_type,
+ name=name,
+ cronstr=cronstr,
+ active=active,
+ generator_id=generator_id,
+ parameters=parameters,
+ )
+ db.session.add(automation)
+ db.session.flush()
+ AssetAuditLog.add_record(
+ asset, f"Created automation '{name}' ({automation.id}) via {origin}."
+ )
+ return automation, warnings
+
+
+def update_automation(
+ automation: Automation,
+ name: str | None = None,
+ cronstr: str | None = None,
+ active: bool | None = None,
+ origin: str = "API",
+) -> list[str]:
+ """Update an automation's name, cron string and/or activation status (not committed yet).
+
+ An audit log record is added to the asset.
+
+ :returns: a list of (human-readable) changes; empty if nothing changed.
+ """
+ from flexmeasures.data.models.audit_log import AssetAuditLog
+
+ changes = []
+ if name is not None and name != automation.name:
+ changes.append(f"name: '{automation.name}' → '{name}'")
+ automation.name = name
+ if cronstr is not None and cronstr != automation.cronstr:
+ changes.append(f"cron string: '{automation.cronstr}' → '{cronstr}'")
+ automation.cronstr = cronstr
+ if active is not None and active != automation.active:
+ changes.append("activated" if active else "deactivated")
+ automation.active = active
+ if changes:
+ AssetAuditLog.add_record(
+ automation.asset,
+ f"Updated automation '{automation.name}' ({automation.id}): {'; '.join(changes)}. Via {origin}.",
+ )
+ return changes
+
+
+def delete_automation(automation: Automation, origin: str = "API"):
+ """Delete an automation (not committed yet), recording it in the asset's audit log."""
+ from flexmeasures.data.models.audit_log import AssetAuditLog
+
+ AssetAuditLog.add_record(
+ automation.asset,
+ f"Deleted automation '{automation.name}' ({automation.id}) via {origin}.",
+ )
+ db.session.delete(automation)
+
+
def run_automation(automation: Automation) -> dict[str, Any] | None:
"""Queue the jobs for one run of an automation.
diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json
index a1e3535645..65ce33c148 100644
--- a/flexmeasures/ui/static/openapi-specs.json
+++ b/flexmeasures/ui/static/openapi-specs.json
@@ -3093,6 +3093,55 @@
}
},
"/api/v3_0/assets/{id}/automations/{automation_id}": {
+ "delete": {
+ "summary": "Delete an automation.",
+ "description": "Delete the automation. Any jobs it already queued are unaffected.\nRequires account admin or consultant rights.\n",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "description": "ID of the asset.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "automation_id",
+ "required": true,
+ "description": "ID of the automation.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "DELETED"
+ },
+ "400": {
+ "description": "INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS"
+ },
+ "401": {
+ "description": "UNAUTHORIZED"
+ },
+ "403": {
+ "description": "INVALID_SENDER"
+ },
+ "404": {
+ "description": "NOT_FOUND"
+ }
+ },
+ "tags": [
+ "Assets"
+ ]
+ },
"get": {
"summary": "Get details of one automation defined on an asset.",
"description": "In addition to the fields shown when listing automations, the response shows\nthe automation's parameters (for forecasts, these are the forecast parameters\nused on each run), information about the data generator that runs it,\nand counts of recently created jobs, per job status.\nNote that jobs in Redis have a limited TTL, so not all past jobs will be counted.\n",
@@ -3175,6 +3224,75 @@
"tags": [
"Assets"
]
+ },
+ "patch": {
+ "summary": "Update an automation's name, cron string or activation status.",
+ "description": "Any subset of the fields `name`, `cronstr` and `active` can be sent.\nOther automation fields cannot be updated; instead, create a new automation.\nRequires account admin or consultant rights.\n",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "description": "ID of the asset.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "path",
+ "name": "automation_id",
+ "required": true,
+ "description": "ID of the automation.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AutomationUpdateSchema"
+ },
+ "examples": {
+ "deactivate": {
+ "summary": "Deactivate the automation",
+ "value": {
+ "active": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "PROCESSED"
+ },
+ "400": {
+ "description": "INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS"
+ },
+ "401": {
+ "description": "UNAUTHORIZED"
+ },
+ "403": {
+ "description": "INVALID_SENDER"
+ },
+ "404": {
+ "description": "NOT_FOUND"
+ },
+ "422": {
+ "description": "UNPROCESSABLE_ENTITY"
+ }
+ },
+ "tags": [
+ "Assets"
+ ]
}
},
"/api/v3_0/assets/{id}/automations": {
@@ -3240,6 +3358,68 @@
"tags": [
"Assets"
]
+ },
+ "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",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "description": "ID of the asset to create the automation on.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AutomationCreationSchema"
+ },
+ "examples": {
+ "daily_forecasts": {
+ "summary": "Daily forecasts of sensor 2092",
+ "value": {
+ "name": "Day-ahead PV forecasts",
+ "cronstr": "0 6 *",
+ "type": "forecasts",
+ "parameters": {
+ "sensor": 2092
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "CREATED"
+ },
+ "400": {
+ "description": "INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS"
+ },
+ "401": {
+ "description": "UNAUTHORIZED"
+ },
+ "403": {
+ "description": "INVALID_SENDER"
+ },
+ "422": {
+ "description": "UNPROCESSABLE_ENTITY"
+ }
+ },
+ "tags": [
+ "Assets"
+ ]
}
},
"/api/v3_0/assets/{id}/chart": {
@@ -5371,6 +5551,67 @@
],
"additionalProperties": false
},
+ "AutomationCreationSchema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "default": "forecasts",
+ "enum": [
+ "forecasts",
+ "schedules"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80
+ },
+ "cronstr": {
+ "type": "string"
+ },
+ "active": {
+ "type": "boolean",
+ "default": true
+ },
+ "parameters": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "forecaster": {
+ "type": "string",
+ "default": "TrainPredictPipeline",
+ "description": "Forecaster class (only used for type 'forecasts')."
+ },
+ "config": {
+ "type": "object",
+ "description": "Forecaster configuration (only used for type 'forecasts').",
+ "additionalProperties": {}
+ }
+ },
+ "required": [
+ "cronstr",
+ "name"
+ ],
+ "additionalProperties": false
+ },
+ "AutomationUpdateSchema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80
+ },
+ "cronstr": {
+ "type": "string"
+ },
+ "active": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
"CopyAssetSchema": {
"type": "object",
"properties": {
diff --git a/flexmeasures/ui/templates/assets/asset_automations.html b/flexmeasures/ui/templates/assets/asset_automations.html
index 523ae3e2b6..af8fe21277 100644
--- a/flexmeasures/ui/templates/assets/asset_automations.html
+++ b/flexmeasures/ui/templates/assets/asset_automations.html
@@ -25,6 +25,58 @@
+ {% if user_can_manage_automations %}
+
+ New automation
+
+
+
+
+ {% endif %}
+
Forecasts
@@ -57,6 +109,7 @@
{% 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 2/2] 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 `_]