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 @@

status page.

+ {% if user_can_manage_automations %} +
+ +
+ + + + {% endif %} +