Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6e1e10
feat: CRUD for automations via API and UI
Flix6x Jul 11, 2026
073abd9
docs: changelog entry for automations CRUD
Flix6x Jul 11, 2026
2237245
data/services: only let a user automate sensors they can access thems…
Flix6x Aug 5, 2026
4765fd8
api/v3_0: regenerate the OpenAPI specs
Flix6x Aug 5, 2026
5d92ea0
api/v3_0/tests: cover automating an inaccessible sensor
Flix6x Aug 5, 2026
d4bf80e
docs: describe which sensors an automation may involve
Flix6x Aug 5, 2026
d8701c6
data/services: check every sensor a schedule would be recorded on
Flix6x Aug 5, 2026
05e00ac
api/v3_0/tests: cover a schedule aggregated onto an inaccessible sensor
Flix6x Aug 5, 2026
db0a57b
Merge the schedule automations into the automations CRUD
BelhsanHmida Aug 10, 2026
f625330
Merge branch 'work/2293-sync' into work/2294-sync
BelhsanHmida Aug 11, 2026
64ad898
data/services: let the forecaster say which sensors an automation inv…
BelhsanHmida Aug 11, 2026
7a3c38a
api/v3_0: let an automation's timezone be set and changed through the…
BelhsanHmida Aug 11, 2026
5612387
data/services: set up the forecaster's data source only once the auto…
BelhsanHmida Aug 11, 2026
87f670b
Merge reviewed schedule automation fixes
BelhsanHmida Aug 11, 2026
1c7b3d3
fix(data/services): hide inaccessible sensor names
BelhsanHmida Aug 11, 2026
b7979b5
test(api/v3_0): isolate automation endpoint tests
BelhsanHmida Aug 11, 2026
10b9155
feat(ui/views): provide automation timezones
BelhsanHmida Aug 11, 2026
a39c4ed
feat(ui): edit automation recurrence timezones
BelhsanHmida Aug 11, 2026
69fd5bf
test(ui): cover automation timezone controls
BelhsanHmida Aug 11, 2026
aa469bc
docs(changelog): mention automation timezones
BelhsanHmida Aug 11, 2026
9b5af64
fix(data/services): preserve schedule validation errors
BelhsanHmida Aug 11, 2026
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
1 change: 1 addition & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +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": <id>}``), 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 <flex_models_and_schedulers>`), rather than as a limit that may be breached at a price.
- 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 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/<uuid>`` 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/<id>/schedules/trigger`, `/assets/<id>/schedules/trigger`, and `/sensors/<id>/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``.
Expand Down
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ New features
* In the UI, the full record of the data source selected on a sensor page can be inspected, backed by a new API endpoint (``[GET] /sources/(id)``) [see `PR #2290 <https://www.github.com/FlexMeasures/flexmeasures/pull/2290>`_]
* Let forecast automations use their own timezone and catch up only the latest missed occurrence after downtime, with skipped daylight-saving times handled once and repeated wall-clock times not duplicated [see `PR #2396 <https://github.com/FlexMeasures/flexmeasures/pull/2396>`_]
* 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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2293>`_]
* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by organisation admins and consultants, with their recurrence expressed in a selectable IANA timezone, 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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2294>`_]
* ``flexmeasures show data-sources`` now shows which organisation a data source belongs to, and can list the sensors holding data recorded by a given source [see `PR #2401 <https://www.github.com/FlexMeasures/flexmeasures/pull/2401>`_]
* New ``inflexible-consumption`` and ``inflexible-production`` flex-context fields make explicit how the sign of each inflexible device's power data should be read (positive values denote consumption resp. production), accepting sensor references with optional source filters; they replace the now-deprecated ``inflexible-device-sensors`` field (bare sensor IDs, sign read from each sensor's ``consumption_is_positive`` attribute), which remains supported [see `PR #2358 <https://www.github.com/FlexMeasures/flexmeasures/pull/2358>`_]
* An inflexible (unschedulable) device can be modelled as its own asset by giving its flex-model entry a single ``inflexible-consumption`` or ``inflexible-production`` sensor reference; such a device joins a ``group`` like any other member, so its fixed (measured) load counts towards the group's intermediate power constraint [see `PR #2374 <https://www.github.com/FlexMeasures/flexmeasures/pull/2374>`_]
Expand Down
6 changes: 6 additions & 0 deletions documentation/features/forecasting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,10 @@ Automations defined on an asset can be viewed on the asset's *Automations* page
An automation's details show the sensors it reads from and writes to, linking to each sensor's page.
Conversely, a sensor's page lists the automations that write data to it.

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`.
6 changes: 6 additions & 0 deletions flexmeasures/api/v3_0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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 (
Expand Down Expand Up @@ -222,6 +226,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)),
Expand Down
243 changes: 242 additions & 1 deletion flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,20 @@
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 (
AutomationSensorsUnknown,
create_automation,
delete_automation as remove_automation,
describe_cronstr,
get_automation_job_stats,
get_automation_sensors,
resolve_automation_sensors,
update_automation,
)
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.queries.generic_assets import (
Expand Down Expand Up @@ -1572,6 +1579,240 @@ def get_automation(self, id: int, automation_id: int, asset: GenericAsset):
automation_data["redis_connection_err"] = redis_connection_err
return automation_data, 200

@route("/<id>/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.

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:
- 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"],
timezone=automation_data["timezone"],
automation_type=automation_data["type"],
active=automation_data["active"],
parameters=automation_data["parameters"],
forecaster_class=automation_data["forecaster"],
config=automation_data["config"],
origin="API",
check_permissions=True,
)
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("/<id>/automations/<int:automation_id>", 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("/<id>/automations/<int:automation_id>", 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("/<id>/jobs", methods=["GET"])
@use_kwargs(
{"asset": AssetIdField(data_key="id")},
Expand Down
Loading
Loading