From 12705febe0a73b61a4a7387f9ee83dd7c4129e7f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 22 Jul 2026 23:32:14 +0200 Subject: [PATCH 1/7] Accept legacy request field names alongside new canonical ones Adds a shared `SupportsLegacyFieldAliases` pre_load mixin (flexmeasures/data/schemas/utils.py) and applies it so several request fields now accept both spellings without breaking existing clients: - `force_new_job_creation` / `force-new-job-creation` on the asset-level scheduling trigger (previously only the sensor-level endpoint accepted both; this is what flexmeasures-client#218 had to work around client-side with server-version sniffing). - `per_page`/`per-page`, `sort_by`/`sort-by`, `sort_dir`/`sort-dir` on all paginated list endpoints. - `account_id`/`account` and `asset_id`/`asset` on GET /sensors, GET /assets, GET /users query filters. Response fields still need explicit dual-field output for backward compatibility (can't "accept either" on the way out), but request fields don't -- this generalizes the one-off alias hook that already existed for source-account/source-type filtering, so future field renames on the request side can ship immediately, without waiting for a new API version. Co-Authored-By: Claude Sonnet 5 --- documentation/api/change_log.rst | 2 + flexmeasures/api/common/schemas/assets.py | 10 ++- .../api/common/schemas/generic_schemas.py | 16 ++++- flexmeasures/api/common/schemas/users.py | 1 + flexmeasures/api/common/schemas/utils.py | 1 + flexmeasures/api/v3_0/assets.py | 1 + flexmeasures/api/v3_0/sensors.py | 38 +++++++---- flexmeasures/api/v3_0/users.py | 9 ++- .../data/schemas/scheduling/__init__.py | 7 +- flexmeasures/data/schemas/utils.py | 28 ++++++++ flexmeasures/ui/static/openapi-specs.json | 68 +++++++++---------- 11 files changed, 127 insertions(+), 54 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 7a8306e100..963181613c 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -11,6 +11,8 @@ v3.0-32 | July XX, 2026 - Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined. - Added a ``group`` field to the storage flex-model, accepted by the `/assets/(id)/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_ (POST) endpoint, referencing a power sensor representing a group of devices (e.g. a shared inverter or feeder). The group's ``power-capacity`` is enforced as a hard constraint on the group's aggregate power, while its ``consumption-capacity``/``production-capacity`` are enforced as soft constraints with default breach prices; the group's scheduled aggregate power is saved to the group sensor. - The ``group`` field also accepts a ``{"asset": }`` reference (besides ``{"sensor": }``), pointing at an asset whose own (DB-stored) flex-model defines the group's constraints. Such a group defines no power sensor of its own; its aggregate schedule is instead saved via its ``consumption``/``production`` output sensor references, following the same conventions as any other asset-only flex-model entry. This lets the entire flex-model for a device tree (including groups) live in the DB, with ``flex-model`` omitted or empty on the trigger request. +- Fixed: `/assets/(id)/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_ (POST) now also accepts the legacy ``force_new_job_creation`` field name (in addition to ``force-new-job-creation``), matching the sensor-level trigger endpoint. Previously, only the sensor-level endpoint accepted both spellings. +- Several query parameters now additionally accept a hyphenated canonical spelling, while the legacy underscored spelling keeps working: ``per_page``/``per-page``, ``sort_by``/``sort-by``, ``sort_dir``/``sort-dir`` (all paginated list endpoints), ``account_id``/``account`` and ``asset_id``/``asset`` (``GET /sensors``, ``GET /assets``, ``GET /users``). New clients should prefer the hyphenated form. v3.0-31 | 2026-06-01 """""""""""""""""""" diff --git a/flexmeasures/api/common/schemas/assets.py b/flexmeasures/api/common/schemas/assets.py index 5b6b1f0bdf..ceda8d8e13 100644 --- a/flexmeasures/api/common/schemas/assets.py +++ b/flexmeasures/api/common/schemas/assets.py @@ -39,7 +39,13 @@ def _serialize(self, values: list[str], attr, obj, **kwargs) -> str: class AssetAPIQuerySchema(PaginationSchema): + legacy_field_aliases = { + **PaginationSchema.legacy_field_aliases, + "account_id": "account", + } + sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["id", "name", "owner"]), metadata=dict( @@ -55,7 +61,7 @@ class AssetAPIQuerySchema(PaginationSchema): ), ) account = AccountIdField( - data_key="account_id", + data_key="account", required=False, metadata=dict( description="Select assets from a given account (requires read access to that account). Per default, the user's own account is used.", @@ -118,10 +124,12 @@ class PublicAssetAPISchema(Schema): class AssetPaginationSchema(PaginationSchema): sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["id", "name", "resolution"]), ) sort_dir = fields.Str( + data_key="sort-dir", required=False, validate=validate.OneOf(["asc", "desc"]), ) diff --git a/flexmeasures/api/common/schemas/generic_schemas.py b/flexmeasures/api/common/schemas/generic_schemas.py index 58ff88ac53..daf2c199e0 100644 --- a/flexmeasures/api/common/schemas/generic_schemas.py +++ b/flexmeasures/api/common/schemas/generic_schemas.py @@ -1,12 +1,22 @@ from marshmallow import Schema, fields, validate from flexmeasures.api.common.schemas.search import SearchFilterField +from flexmeasures.api.common.schemas.utils import SupportsLegacyFieldAliases -class PaginationSchema(Schema): +class PaginationSchema(SupportsLegacyFieldAliases, Schema): + legacy_field_aliases = { + "per_page": "per-page", + "sort_by": "sort-by", + "sort_dir": "sort-dir", + } + # note: the absence of this parameter would signal to the API to not paginate (so there is no default set here) page = fields.Int(required=False, validate=validate.Range(min=1)) per_page = fields.Int( - required=False, validate=validate.Range(min=1), load_default=10 + data_key="per-page", + required=False, + validate=validate.Range(min=1), + load_default=10, ) filter = SearchFilterField( required=False, @@ -15,12 +25,14 @@ class PaginationSchema(Schema): ), ) sort_by = fields.Str( + data_key="sort-by", required=False, metadata=dict( description="Sort results by this field.", ), ) sort_dir = fields.Str( + data_key="sort-dir", required=False, validate=validate.OneOf(["asc", "desc"]), metadata=dict( diff --git a/flexmeasures/api/common/schemas/users.py b/flexmeasures/api/common/schemas/users.py index ce9cbf65eb..e418a7c067 100644 --- a/flexmeasures/api/common/schemas/users.py +++ b/flexmeasures/api/common/schemas/users.py @@ -73,6 +73,7 @@ def _serialize(self, value: User, attr, obj, **kwargs) -> int: class AccountAPIQuerySchema(PaginationSchema): sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["id", "name", "assets", "users"]), ) diff --git a/flexmeasures/api/common/schemas/utils.py b/flexmeasures/api/common/schemas/utils.py index 3c7174cee2..fac03cc63a 100644 --- a/flexmeasures/api/common/schemas/utils.py +++ b/flexmeasures/api/common/schemas/utils.py @@ -13,6 +13,7 @@ VariableQuantityField, VariableQuantityOpenAPISchema, ) +from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases # noqa: F401 def make_openapi_compatible( # noqa: C901 diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 08eea41154..6e94673829 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -153,6 +153,7 @@ class AssetChartDataKwargsSchema(Schema): class AssetAuditLogPaginationSchema(PaginationSchema): sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["event_datetime"]), ) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index e28a7f0c4a..c1ba9bed83 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -12,7 +12,7 @@ from flask_classful import FlaskView, route from flask_json import as_json from flask_security import auth_required, current_user -from marshmallow import fields, pre_load, Schema, ValidationError, validates_schema +from marshmallow import fields, Schema, ValidationError, validates_schema import marshmallow.validate as validate from rq.job import Job, JobStatus, NoSuchJobError from webargs.flaskparser import use_args, use_kwargs @@ -26,7 +26,10 @@ unprocessable_entity, fallback_schedule_redirect, ) -from flexmeasures.api.common.schemas.utils import make_openapi_compatible +from flexmeasures.api.common.schemas.utils import ( + make_openapi_compatible, + SupportsLegacyFieldAliases, +) from flexmeasures.api.common.schemas.sensor_data import ( # noqa F401 SensorDataDescriptionSchema, GetSensorDataSchema, @@ -220,14 +223,23 @@ def validate_time_window(self, data, **kwargs): ) -class SensorKwargsSchema(Schema): - account = AccountIdField(data_key="account_id", required=False) - asset = AssetIdField(data_key="asset_id", required=False) +class SensorKwargsSchema(SupportsLegacyFieldAliases, Schema): + legacy_field_aliases = { + "account_id": "account", + "asset_id": "asset", + "per_page": "per-page", + } + + account = AccountIdField(data_key="account", required=False) + asset = AssetIdField(data_key="asset", required=False) include_consultancy_clients = fields.Boolean(required=False, load_default=False) include_public_assets = fields.Boolean(required=False, load_default=False) page = fields.Int(required=False, validate=validate.Range(min=1)) per_page = fields.Int( - required=False, validate=validate.Range(min=1), load_default=10 + data_key="per-page", + required=False, + validate=validate.Range(min=1), + load_default=10, ) filter = SearchFilterField( required=False, @@ -238,7 +250,11 @@ class SensorKwargsSchema(Schema): unit = UnitField(required=False) -class TriggerScheduleKwargsSchema(Schema): +class TriggerScheduleKwargsSchema(SupportsLegacyFieldAliases, Schema): + legacy_field_aliases = { + "force_new_job_creation": "force-new-job-creation", + } + start_of_schedule = AwareDateTimeField( data_key="start", format="iso", @@ -295,14 +311,6 @@ class TriggerScheduleKwargsSchema(Schema): ), ) - @pre_load - def support_legacy_field_name(self, data, **kwargs): - """Accept old snake_case input for backwards compatibility.""" - if "force_new_job_creation" in data and "force-new-job-creation" not in data: - data["force-new-job-creation"] = data.pop("force_new_job_creation") - - return data - class SensorAPI(FlaskView): route_base = "/sensors" diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index 034f4f2e3f..c9959d0556 100644 --- a/flexmeasures/api/v3_0/users.py +++ b/flexmeasures/api/v3_0/users.py @@ -53,6 +53,7 @@ class AuthRequestSchema(Schema): class UserAuditlogSchema(PaginationSchema): sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf( ["username", "email", "event_datetime", "lastLogin", "lastSeen"] @@ -61,11 +62,17 @@ class UserAuditlogSchema(PaginationSchema): class UserAPIQuerySchema(PaginationSchema): + legacy_field_aliases = { + **PaginationSchema.legacy_field_aliases, + "account_id": "account", + } + sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["username", "email", "lastLogin", "lastSeen"]), ) - account = AccountIdField(data_key="account_id", required=False) + account = AccountIdField(data_key="account", required=False) include_inactive = fields.Bool(load_default=False) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index bee68ad689..fa851d4a2b 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -29,6 +29,7 @@ ) from flexmeasures.data.schemas.scheduling import metadata from flexmeasures.data.schemas.units import UnitField +from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases from flexmeasures.utils.doc_utils import rst_to_openapi from flexmeasures.data.schemas.times import ( AwareDateTimeField, @@ -1498,7 +1499,7 @@ def wrap_with_envelope(self, data, **kwargs): return dict(**data, **sensor_flex_model) -class AssetTriggerSchema(Schema): +class AssetTriggerSchema(SupportsLegacyFieldAliases, Schema): """ { "start": "2025-01-21T15:00+01", @@ -1515,6 +1516,10 @@ class AssetTriggerSchema(Schema): } """ + legacy_field_aliases = { + "force_new_job_creation": "force-new-job-creation", + } + asset = GenericAssetIdField( data_key="id", metadata=dict( diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 9557eadec0..12f4067f27 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -134,3 +134,31 @@ def snake_to_kebab(key: str) -> str: def kebab_to_snake(key: str) -> str: """Convert kebab-case to snake_case.""" return key.replace("-", "_") + + +class SupportsLegacyFieldAliases: + """Mixin that lets a request schema accept legacy field names as aliases for their canonical replacements. + + Subclasses set `legacy_field_aliases`, a dict mapping a legacy incoming + key (as sent by an older client) to the schema's current, canonical + `data_key`. This lets us rename a public request field without breaking + clients that still send the old name: both are accepted, and only the + canonical field ends up being deserialized. + + This is for request fields only. Response fields can't "accept either" + key on the way out, so backward compatibility there means including both + keys in the response body instead (see e.g. `job`/`schedule` in the + scheduling trigger response). + """ + + legacy_field_aliases: dict[str, str] = {} + + @ma.pre_load + def _apply_legacy_field_aliases(self, data, **kwargs): + if not hasattr(data, "items") or not self.legacy_field_aliases: + return data + aliased = dict(data) + for legacy_key, canonical_key in self.legacy_field_aliases.items(): + if legacy_key in aliased and canonical_key not in aliased: + aliased[canonical_key] = aliased.pop(legacy_key) + return aliased diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 506039e6c2..b30c0c7ab4 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1019,7 +1019,7 @@ "parameters": [ { "in": "query", - "name": "account_id", + "name": "account", "schema": { "type": "integer" }, @@ -1027,7 +1027,7 @@ }, { "in": "query", - "name": "asset_id", + "name": "asset", "schema": { "type": "integer" }, @@ -1062,7 +1062,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -1903,7 +1903,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -1922,7 +1922,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "schema": { "type": "string", "enum": [ @@ -1936,7 +1936,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "description": "Sort direction for the results. Ascending ('asc') or descending ('desc').", "schema": { "type": "string", @@ -2134,7 +2134,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -2153,7 +2153,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "schema": { "type": "string", "enum": [ @@ -2168,7 +2168,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "description": "Sort direction for the results. Ascending ('asc') or descending ('desc').", "schema": { "type": "string", @@ -2401,7 +2401,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -2420,7 +2420,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "schema": { "type": "string", "enum": [ @@ -2434,7 +2434,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "description": "Sort direction for the results. Ascending ('asc') or descending ('desc').", "schema": { "type": "string", @@ -2447,7 +2447,7 @@ }, { "in": "query", - "name": "account_id", + "name": "account", "schema": { "type": "integer" }, @@ -2667,7 +2667,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -2686,7 +2686,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "schema": { "type": "string", "enum": [ @@ -2699,7 +2699,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "schema": { "type": "string", "enum": [ @@ -2802,7 +2802,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -2821,7 +2821,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "schema": { "type": "string", "enum": [ @@ -2832,7 +2832,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "description": "Sort direction for the results. Ascending ('asc') or descending ('desc').", "schema": { "type": "string", @@ -3634,7 +3634,7 @@ }, { "in": "query", - "name": "per_page", + "name": "per-page", "schema": { "type": "integer", "default": 10, @@ -3653,7 +3653,7 @@ }, { "in": "query", - "name": "sort_by", + "name": "sort-by", "description": "Sort results by this field.", "schema": { "type": "string", @@ -3667,7 +3667,7 @@ }, { "in": "query", - "name": "sort_dir", + "name": "sort-dir", "description": "Sort direction for the results. Ascending ('asc') or descending ('desc').", "schema": { "type": "string", @@ -3691,7 +3691,7 @@ }, { "in": "query", - "name": "account_id", + "name": "account", "description": "Select assets from a given account (requires read access to that account). Per default, the user's own account is used.", "schema": { "type": "integer", @@ -5087,7 +5087,7 @@ "type": "integer", "minimum": 1 }, - "per_page": { + "per-page": { "type": "integer", "default": 10, "minimum": 1 @@ -5096,7 +5096,7 @@ "type": "string", "description": "Filter results by this keyword." }, - "sort_by": { + "sort-by": { "type": "string", "enum": [ "username", @@ -5105,7 +5105,7 @@ "lastSeen" ] }, - "sort_dir": { + "sort-dir": { "type": "string", "enum": [ "asc", @@ -5113,7 +5113,7 @@ ], "description": "Sort direction for the results. Ascending ('asc') or descending ('desc')." }, - "account_id": { + "account": { "type": "integer" }, "include_inactive": { @@ -5130,7 +5130,7 @@ "type": "integer", "minimum": 1 }, - "per_page": { + "per-page": { "type": "integer", "default": 10, "minimum": 1 @@ -5139,7 +5139,7 @@ "type": "string", "description": "Filter results by this keyword." }, - "sort_by": { + "sort-by": { "type": "string", "enum": [ "id", @@ -5148,7 +5148,7 @@ ], "description": "Sort results by this field." }, - "sort_dir": { + "sort-dir": { "type": "string", "enum": [ "asc", @@ -5162,7 +5162,7 @@ "description": "Which fields to include in response. List fields separated by '|' (pipe).", "example": "id|name|flex_model" }, - "account_id": { + "account": { "type": "integer", "description": "Select assets from a given account (requires read access to that account). Per default, the user's own account is used.", "example": 67 @@ -5602,7 +5602,7 @@ "type": "integer", "minimum": 1 }, - "per_page": { + "per-page": { "type": "integer", "default": 10, "minimum": 1 @@ -5611,7 +5611,7 @@ "type": "string", "description": "Filter results by this keyword." }, - "sort_by": { + "sort-by": { "type": "string", "enum": [ "id", @@ -5620,7 +5620,7 @@ "users" ] }, - "sort_dir": { + "sort-dir": { "type": "string", "enum": [ "asc", From 07c4e1fc516082d7c613a80f849105704afac77b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 23 Jul 2026 00:10:40 +0200 Subject: [PATCH 2/7] Fix: don't strip MultiDict semantics when no legacy field is present _apply_legacy_field_aliases unconditionally rebuilt the incoming request data into a plain dict, even when none of its declared legacy aliases were present. That silently broke AssetTriggerSchema.normalize_flex_context_format, which detects a multi-commodity flex-context list via MultiDict.getlist() -- CI caught this via test_asset_trigger_with_multi_commodity_flex_context failing (the scheduling job never finished, since flex-context normalization saw a single dict instead of the intended commodities list). Now the hook only rebuilds `data` when one of its own legacy keys is actually present, leaving MultiDict-like inputs untouched otherwise. Co-Authored-By: Claude Sonnet 5 --- flexmeasures/data/schemas/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 12f4067f27..b860712ebd 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -157,6 +157,13 @@ class SupportsLegacyFieldAliases: def _apply_legacy_field_aliases(self, data, **kwargs): if not hasattr(data, "items") or not self.legacy_field_aliases: return data + # Only rebuild `data` into a plain dict if a legacy key is actually + # present. Some incoming `data` objects are a MultiDict (e.g. when + # webargs represents a JSON list as repeated keys) and other pre_load + # hooks may rely on that (e.g. `getlist`); leave those untouched when + # there's nothing for us to alias. + if not any(legacy_key in data for legacy_key in self.legacy_field_aliases): + return data aliased = dict(data) for legacy_key, canonical_key in self.legacy_field_aliases.items(): if legacy_key in aliased and canonical_key not in aliased: From ddde7f1b695496caa730c941d042d1ebb59cba84 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 11:49:15 +0200 Subject: [PATCH 3/7] Address Copilot review: preserve MultiDict semantics, add regression tests, fix changelog wording - _apply_legacy_field_aliases now copies the incoming mapping with data.copy() instead of dict(data), so MultiDict methods like getlist survive aliasing even when a legacy key is present alongside other, untouched repeated keys (previously only the "no legacy key present" case was handled). - Added two regression tests for AssetTriggerSchema: accepting the legacy force_new_job_creation field, and preserving getlist() on flex-context while aliasing force_new_job_creation in the same payload. - Reworded the change_log.rst entry: account/asset are not hyphenated, only per-page/sort-by/sort-dir are; call it "canonical (hyphenated where applicable)" instead of blanket "hyphenated". Co-Authored-By: Claude Sonnet 5 --- documentation/api/change_log.rst | 2 +- .../data/schemas/tests/test_scheduling.py | 43 +++++++++++++++++++ flexmeasures/data/schemas/utils.py | 15 ++++--- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 963181613c..896c09c1c1 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -12,7 +12,7 @@ v3.0-32 | July XX, 2026 - Added a ``group`` field to the storage flex-model, accepted by the `/assets/(id)/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_ (POST) endpoint, referencing a power sensor representing a group of devices (e.g. a shared inverter or feeder). The group's ``power-capacity`` is enforced as a hard constraint on the group's aggregate power, while its ``consumption-capacity``/``production-capacity`` are enforced as soft constraints with default breach prices; the group's scheduled aggregate power is saved to the group sensor. - The ``group`` field also accepts a ``{"asset": }`` reference (besides ``{"sensor": }``), pointing at an asset whose own (DB-stored) flex-model defines the group's constraints. Such a group defines no power sensor of its own; its aggregate schedule is instead saved via its ``consumption``/``production`` output sensor references, following the same conventions as any other asset-only flex-model entry. This lets the entire flex-model for a device tree (including groups) live in the DB, with ``flex-model`` omitted or empty on the trigger request. - Fixed: `/assets/(id)/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_ (POST) now also accepts the legacy ``force_new_job_creation`` field name (in addition to ``force-new-job-creation``), matching the sensor-level trigger endpoint. Previously, only the sensor-level endpoint accepted both spellings. -- Several query parameters now additionally accept a hyphenated canonical spelling, while the legacy underscored spelling keeps working: ``per_page``/``per-page``, ``sort_by``/``sort-by``, ``sort_dir``/``sort-dir`` (all paginated list endpoints), ``account_id``/``account`` and ``asset_id``/``asset`` (``GET /sensors``, ``GET /assets``, ``GET /users``). New clients should prefer the hyphenated form. +- Several query parameters now additionally accept a canonical spelling (hyphenated where applicable), while the legacy underscored spelling keeps working: ``per_page``/``per-page``, ``sort_by``/``sort-by``, ``sort_dir``/``sort-dir`` (all paginated list endpoints), ``account_id``/``account`` and ``asset_id``/``asset`` (``GET /sensors``, ``GET /assets``, ``GET /users``). New clients should prefer the canonical form. v3.0-31 | 2026-06-01 """""""""""""""""""" diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index d3db401fcd..3cc1fb0416 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,46 @@ def test_asset_trigger_schema_rejects_malformed_flex_context(app): with pytest.raises(ValidationError) as e_info: schema.normalize_flex_context_format({"flex-context": "not-a-dict-or-list"}) assert "flex-context" in str(e_info.value) + + +def test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field(app): + """test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field: + + Regression test for a gap that made flexmeasures-client#218 necessary: the + sensor-level scheduling trigger schema already accepted both + `force_new_job_creation` (legacy) and `force-new-job-creation` (canonical), + but the asset-level trigger schema only accepted the canonical spelling and + rejected the legacy one (as an unknown field, yielding a 422). + """ + from flexmeasures.data.schemas.scheduling import AssetTriggerSchema + + schema = AssetTriggerSchema() + normalized = schema._apply_legacy_field_aliases({"force_new_job_creation": True}) + assert normalized == {"force-new-job-creation": True} + + +def test_asset_trigger_schema_preserves_multidict_when_aliasing(app): + """test_asset_trigger_schema_preserves_multidict_when_aliasing: + + Regression test: aliasing a legacy field must not destroy MultiDict + semantics (e.g. `getlist`) for other, untouched keys -- this is relied on + by `normalize_flex_context_format` to detect a multi-commodity + `flex-context` list sent as repeated keys. + """ + from werkzeug.datastructures import MultiDict + + from flexmeasures.data.schemas.scheduling import AssetTriggerSchema + + schema = AssetTriggerSchema() + data = MultiDict( + [ + ("start", "2026-01-15T10:00+01:00"), + ("force_new_job_creation", True), + ("flex-context", "electricity"), + ("flex-context", "heat"), + ] + ) + normalized = schema._apply_legacy_field_aliases(data) + assert normalized.getlist("flex-context") == ["electricity", "heat"] + assert normalized["force-new-job-creation"] is True + assert "force_new_job_creation" not in normalized diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index b860712ebd..62ce55b7b6 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -157,14 +157,17 @@ class SupportsLegacyFieldAliases: def _apply_legacy_field_aliases(self, data, **kwargs): if not hasattr(data, "items") or not self.legacy_field_aliases: return data - # Only rebuild `data` into a plain dict if a legacy key is actually - # present. Some incoming `data` objects are a MultiDict (e.g. when - # webargs represents a JSON list as repeated keys) and other pre_load - # hooks may rely on that (e.g. `getlist`); leave those untouched when - # there's nothing for us to alias. + # Skip entirely when nothing to alias, so schemas that don't touch + # this hook's keys never pay for a copy. if not any(legacy_key in data for legacy_key in self.legacy_field_aliases): return data - aliased = dict(data) + # Some incoming `data` objects are a MultiDict (e.g. when webargs + # represents a JSON list as repeated keys), and other `@pre_load` + # hooks may rely on MultiDict methods like `getlist` for keys we + # don't touch here (e.g. `AssetTriggerSchema.normalize_flex_context_format`). + # `.copy()` preserves the original mapping type; plain dict-item + # assignment/pop on it is safe for our single-valued legacy keys. + aliased = data.copy() if hasattr(data, "copy") else dict(data) for legacy_key, canonical_key in self.legacy_field_aliases.items(): if legacy_key in aliased and canonical_key not in aliased: aliased[canonical_key] = aliased.pop(legacy_key) From 84cd53fe4a8637bb559bef73bbcfdd02a27ec577 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 12:34:25 +0200 Subject: [PATCH 4/7] Address Copilot follow-up: test via schema.load(), not the private hook directly test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field only called _apply_legacy_field_aliases directly, so it wouldn't catch a regression where the @pre_load hook stops being registered/applied by Marshmallow. Added a second test that goes through the real AssetTriggerSchema.load(...) path with a nonexistent asset id, asserting the resulting ValidationError complains about the asset, never about force_new_job_creation being an unrecognized field. Co-Authored-By: Claude Sonnet 5 --- .../data/schemas/tests/test_scheduling.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 3cc1fb0416..e96a579a29 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1473,6 +1473,43 @@ def test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field(app): assert normalized == {"force-new-job-creation": True} +def test_asset_trigger_schema_load_accepts_legacy_force_new_job_creation_field(db, app): + """test_asset_trigger_schema_load_accepts_legacy_force_new_job_creation_field: + + Same regression as above, but exercised through the real `schema.load(...)` + deserialization path (not by calling the `@pre_load` helper directly), so + this fails if the hook ever stops being registered/applied by Marshmallow + (e.g. decorator removed, or an MRO change means the hook is no longer + picked up). + + Uses a nonexistent asset id, so `load()` is expected to still raise -- but + only about the asset, never about `force_new_job_creation` being an + unrecognized field. If the legacy alias stopped working, Marshmallow's + default `unknown` handling would additionally report `force_new_job_creation` + as an unknown field. + """ + from flexmeasures.data.schemas.scheduling import AssetTriggerSchema + + schema = AssetTriggerSchema() + with pytest.raises(ValidationError) as e_info: + schema.load( + { + "id": 2**31 - 1, # some asset id that doesn't exist + "start": "2026-01-15T10:00:00+01:00", + "force_new_job_creation": True, # legacy spelling + } + ) + messages = e_info.value.messages + assert "force_new_job_creation" not in messages, ( + "the legacy field name should have been aliased to " + "`force-new-job-creation` before validation, not rejected as an " + f"unknown field; got: {messages}" + ) + assert ( + "id" in messages + ), f"expected the (nonexistent) asset id to fail; got: {messages}" + + def test_asset_trigger_schema_preserves_multidict_when_aliasing(app): """test_asset_trigger_schema_preserves_multidict_when_aliasing: From 23ed023751c91b4cca03e96a07eeb2927814455f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 16:44:46 +0200 Subject: [PATCH 5/7] Move force_new_job_creation legacy alias out of the shared domain schema AssetTriggerSchema (flexmeasures/data/schemas/scheduling/__init__.py) is also used outside the versioned API, e.g. by the CLI. Baking v3_0-only backward compatibility (accepting the legacy force_new_job_creation field name) directly into it meant that cruft would permanently linger there even after v3_0 is eventually sunset, since nothing forces its removal -- unlike route/schema code that lives inside flexmeasures/api/v3_0/, which gets deleted wholesale when a version is retired (see how v1/v2_0 sunset already works in flexmeasures/api/sunset/__init__.py). Introduces AssetTriggerSchemaV3, a thin v3_0-scoped subclass in flexmeasures/api/v3_0/assets.py that adds the legacy alias, while AssetTriggerSchema itself stays canonical. This mirrors how the sensor-level equivalent (TriggerScheduleKwargsSchema) was already scoped correctly, inside flexmeasures/api/v3_0/sensors.py. Moved the corresponding tests from the domain schema test file to a new flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py. Co-Authored-By: Claude Sonnet 5 Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 19 ++++- .../tests/test_asset_trigger_schema_v3.py | 80 ++++++++++++++++++ .../data/schemas/scheduling/__init__.py | 15 ++-- .../data/schemas/tests/test_scheduling.py | 84 ++----------------- 4 files changed, 113 insertions(+), 85 deletions(-) create mode 100644 flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 6e94673829..b6341deead 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -58,6 +58,7 @@ SensorsToShowSchema, ) from flexmeasures.data.schemas.scheduling import AssetTriggerSchema +from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases from flexmeasures.data.services.scheduling import ( create_sequential_scheduling_job, create_simultaneous_scheduling_job, @@ -99,6 +100,22 @@ def sensor_term_filter(term: str): return or_(*filters) +class AssetTriggerSchemaV3(SupportsLegacyFieldAliases, AssetTriggerSchema): + """v3_0-only wrapper around the shared `AssetTriggerSchema`. + + `AssetTriggerSchema` itself stays canonical (no legacy field-name + aliasing) since it's also used outside the versioned API, e.g. by the + CLI. This subclass adds v3_0's backward compatibility for the legacy + `force_new_job_creation` field name, so it can be deleted in one place, + along with the rest of `flexmeasures/api/v3_0/`, once this API version is + sunset -- without needing to touch the shared domain schema. + """ + + legacy_field_aliases = { + "force_new_job_creation": "force-new-job-creation", + } + + class AssetTriggerOpenAPISchema(AssetTriggerSchema): def __init__(self, *args, **kwargs): @@ -1428,7 +1445,7 @@ def update_keep_legends_below_graphs(self, **kwargs): }, 200 @route("//schedules/trigger", methods=["POST"]) - @use_args(AssetTriggerSchema(), location="args_and_json", as_kwargs=True) + @use_args(AssetTriggerSchemaV3(), location="args_and_json", as_kwargs=True) # Simplification of checking for create-children access on each of the flexible sensors, # which assumes each of the flexible sensors belongs to the given asset. @permission_required_for_context("create-children", ctx_arg_name="asset") diff --git a/flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py b/flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py new file mode 100644 index 0000000000..fa5a4d05f0 --- /dev/null +++ b/flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py @@ -0,0 +1,80 @@ +"""Tests for AssetTriggerSchemaV3, the v3_0-only wrapper that adds legacy +field-name backward compatibility on top of the shared, canonical +AssetTriggerSchema (see flexmeasures/api/v3_0/assets.py for why this split +exists: sunsetting v3_0 should be able to delete this compatibility layer in +one place, without touching the domain schema also used by the CLI). +""" + +from marshmallow.validate import ValidationError +import pytest +from werkzeug.datastructures import MultiDict + +from flexmeasures.api.v3_0.assets import AssetTriggerSchemaV3 + + +def test_asset_trigger_schema_v3_accepts_legacy_force_new_job_creation_field(): + """Regression test for a gap that made flexmeasures-client#218 necessary: the + sensor-level scheduling trigger schema already accepted both + `force_new_job_creation` (legacy) and `force-new-job-creation` (canonical), + but the asset-level trigger schema only accepted the canonical spelling and + rejected the legacy one (as an unknown field, yielding a 422). + """ + schema = AssetTriggerSchemaV3() + normalized = schema._apply_legacy_field_aliases({"force_new_job_creation": True}) + assert normalized == {"force-new-job-creation": True} + + +def test_asset_trigger_schema_v3_load_accepts_legacy_force_new_job_creation_field( + db, app +): + """Same regression as above, but exercised through the real `schema.load(...)` + deserialization path (not by calling the `@pre_load` helper directly), so + this fails if the hook ever stops being registered/applied by Marshmallow + (e.g. decorator removed, or an MRO change means the hook is no longer + picked up). + + Uses a nonexistent asset id, so `load()` is expected to still raise -- but + only about the asset, never about `force_new_job_creation` being an + unrecognized field. If the legacy alias stopped working, Marshmallow's + default `unknown` handling would additionally report `force_new_job_creation` + as an unknown field. + """ + schema = AssetTriggerSchemaV3() + with pytest.raises(ValidationError) as e_info: + schema.load( + { + "id": 2**31 - 1, # some asset id that doesn't exist + "start": "2026-01-15T10:00:00+01:00", + "force_new_job_creation": True, # legacy spelling + } + ) + messages = e_info.value.messages + assert "force_new_job_creation" not in messages, ( + "the legacy field name should have been aliased to " + "`force-new-job-creation` before validation, not rejected as an " + f"unknown field; got: {messages}" + ) + assert ( + "id" in messages + ), f"expected the (nonexistent) asset id to fail; got: {messages}" + + +def test_asset_trigger_schema_v3_preserves_multidict_when_aliasing(): + """Regression test: aliasing a legacy field must not destroy MultiDict + semantics (e.g. `getlist`) for other, untouched keys -- this is relied on + by `AssetTriggerSchema.normalize_flex_context_format` to detect a + multi-commodity `flex-context` list sent as repeated keys. + """ + schema = AssetTriggerSchemaV3() + data = MultiDict( + [ + ("start", "2026-01-15T10:00+01:00"), + ("force_new_job_creation", True), + ("flex-context", "electricity"), + ("flex-context", "heat"), + ] + ) + normalized = schema._apply_legacy_field_aliases(data) + assert normalized.getlist("flex-context") == ["electricity", "heat"] + assert normalized["force-new-job-creation"] is True + assert "force_new_job_creation" not in normalized diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index fa851d4a2b..2cebbce3de 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -29,7 +29,6 @@ ) from flexmeasures.data.schemas.scheduling import metadata from flexmeasures.data.schemas.units import UnitField -from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases from flexmeasures.utils.doc_utils import rst_to_openapi from flexmeasures.data.schemas.times import ( AwareDateTimeField, @@ -1499,7 +1498,7 @@ def wrap_with_envelope(self, data, **kwargs): return dict(**data, **sensor_flex_model) -class AssetTriggerSchema(SupportsLegacyFieldAliases, Schema): +class AssetTriggerSchema(Schema): """ { "start": "2025-01-21T15:00+01", @@ -1514,11 +1513,15 @@ class AssetTriggerSchema(SupportsLegacyFieldAliases, Schema): }, ] } - """ - legacy_field_aliases = { - "force_new_job_creation": "force-new-job-creation", - } + This schema is also used outside of the (versioned) API, e.g. by the CLI, + so it stays canonical (no legacy field-name aliasing here). API-version- + specific backward compatibility, such as accepting the legacy + `force_new_job_creation` field name, is layered on top in + `flexmeasures/api/v3_0/assets.py` (`AssetTriggerSchemaV3`), so it can be + deleted in one place once v3_0 is sunset, without touching this shared + domain schema. + """ asset = GenericAssetIdField( data_key="id", diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index e96a579a29..b1913251c1 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1457,81 +1457,9 @@ def test_asset_trigger_schema_rejects_malformed_flex_context(app): assert "flex-context" in str(e_info.value) -def test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field(app): - """test_asset_trigger_schema_accepts_legacy_force_new_job_creation_field: - - Regression test for a gap that made flexmeasures-client#218 necessary: the - sensor-level scheduling trigger schema already accepted both - `force_new_job_creation` (legacy) and `force-new-job-creation` (canonical), - but the asset-level trigger schema only accepted the canonical spelling and - rejected the legacy one (as an unknown field, yielding a 422). - """ - from flexmeasures.data.schemas.scheduling import AssetTriggerSchema - - schema = AssetTriggerSchema() - normalized = schema._apply_legacy_field_aliases({"force_new_job_creation": True}) - assert normalized == {"force-new-job-creation": True} - - -def test_asset_trigger_schema_load_accepts_legacy_force_new_job_creation_field(db, app): - """test_asset_trigger_schema_load_accepts_legacy_force_new_job_creation_field: - - Same regression as above, but exercised through the real `schema.load(...)` - deserialization path (not by calling the `@pre_load` helper directly), so - this fails if the hook ever stops being registered/applied by Marshmallow - (e.g. decorator removed, or an MRO change means the hook is no longer - picked up). - - Uses a nonexistent asset id, so `load()` is expected to still raise -- but - only about the asset, never about `force_new_job_creation` being an - unrecognized field. If the legacy alias stopped working, Marshmallow's - default `unknown` handling would additionally report `force_new_job_creation` - as an unknown field. - """ - from flexmeasures.data.schemas.scheduling import AssetTriggerSchema - - schema = AssetTriggerSchema() - with pytest.raises(ValidationError) as e_info: - schema.load( - { - "id": 2**31 - 1, # some asset id that doesn't exist - "start": "2026-01-15T10:00:00+01:00", - "force_new_job_creation": True, # legacy spelling - } - ) - messages = e_info.value.messages - assert "force_new_job_creation" not in messages, ( - "the legacy field name should have been aliased to " - "`force-new-job-creation` before validation, not rejected as an " - f"unknown field; got: {messages}" - ) - assert ( - "id" in messages - ), f"expected the (nonexistent) asset id to fail; got: {messages}" - - -def test_asset_trigger_schema_preserves_multidict_when_aliasing(app): - """test_asset_trigger_schema_preserves_multidict_when_aliasing: - - Regression test: aliasing a legacy field must not destroy MultiDict - semantics (e.g. `getlist`) for other, untouched keys -- this is relied on - by `normalize_flex_context_format` to detect a multi-commodity - `flex-context` list sent as repeated keys. - """ - from werkzeug.datastructures import MultiDict - - from flexmeasures.data.schemas.scheduling import AssetTriggerSchema - - schema = AssetTriggerSchema() - data = MultiDict( - [ - ("start", "2026-01-15T10:00+01:00"), - ("force_new_job_creation", True), - ("flex-context", "electricity"), - ("flex-context", "heat"), - ] - ) - normalized = schema._apply_legacy_field_aliases(data) - assert normalized.getlist("flex-context") == ["electricity", "heat"] - assert normalized["force-new-job-creation"] is True - assert "force_new_job_creation" not in normalized +# Note: AssetTriggerSchema itself no longer aliases legacy field names (e.g. +# force_new_job_creation) -- that's v3_0-specific backward compatibility, +# layered on top by AssetTriggerSchemaV3 in flexmeasures/api/v3_0/assets.py, +# and tested there (flexmeasures/api/v3_0/tests/test_asset_trigger_schema_v3.py). +# This schema stays canonical since it's also used outside the API, e.g. by +# the CLI. From ac35890d974a5ed7fc1e483a0fc7c0aa97f54f40 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 16:56:13 +0200 Subject: [PATCH 6/7] Address Copilot: fix stale legacy param names in OpenAPI descriptions The generated OpenAPI spec's endpoint descriptions still referenced account_id/per_page/sort_by/sort_dir in prose, even though the documented parameter list (and the schema itself) had moved to the canonical account/per-page/sort-by/sort-dir names -- internally inconsistent docs. Updated the docstrings for the assets, sensors, users, and accounts list endpoints (plus their audit-log endpoints) to reference the canonical names, noting the legacy alias parenthetically where relevant. Co-Authored-By: Claude Sonnet 5 Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/accounts.py | 4 ++-- flexmeasures/api/v3_0/assets.py | 16 ++++++++-------- flexmeasures/api/v3_0/sensors.py | 6 +++--- flexmeasures/api/v3_0/users.py | 10 +++++----- flexmeasures/ui/static/openapi-specs.json | 14 +++++++------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 983d38e454..3b08ffbeaa 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -67,9 +67,9 @@ def index( This endpoint returns all accounts the current user has access to. Accessible accounts include the user's own account, accounts for which the user is a consultant, and all accounts if the user has admin privileges. - The endpoint supports pagination of the account list using the `page` and `per_page` query parameters. + The endpoint supports pagination of the account list using the `page` and `per-page` (legacy alias: `per_page`) query parameters. - If the `page` parameter is not provided, all accounts are returned, without pagination information. The result will be a list of accounts. - - If a `page` parameter is provided, the response will be paginated, showing a specific number of accounts per page as defined by `per_page` (default is 10). + - If a `page` parameter is provided, the response will be paginated, showing a specific number of accounts per page as defined by `per-page` (default is 10). - If a search 'filter' such as 'solar "ACME corp"' is provided, the response will filter out accounts where each search term is either present in their name. The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index b6341deead..11b99c1d49 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -316,16 +316,16 @@ def index( description: | This endpoint returns all assets that are accessible by the user after applying optional filters. - - The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used. + - The `account` query parameter (legacy alias: `account_id`) can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used. - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`. - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`. - The `asset_type` query parameter can be used to filter by generic asset type ID. - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself). - The `depth` query parameter can be used to search only a max number of descendant generations from the root. - The endpoint supports pagination of the asset list using the `page` and `per_page` query parameters. + The endpoint supports pagination of the asset list using the `page` and `per-page` (legacy alias: `per_page`) query parameters. - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets. - - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10). + - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per-page` (default is 10). - If a search 'filter' such as 'solar "ACME corp"' is provided, the response will return only assets where each search term is either present in their name or account name, or is a prefix of their ID. The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data) @@ -473,10 +473,10 @@ def asset_sensors( description: | This endpoint returns all sensors under an asset. - The endpoint supports pagination of the sensor list using the `page` and `per_page` query parameters. + The endpoint supports pagination of the sensor list using the `page` and `per-page` (legacy alias: `per_page`) query parameters. - If the `page` parameter is not provided, all sensors are returned, without pagination information. The result will be a list of sensors. - - If a `page` parameter is provided, the response will be paginated, showing a specific number of sensors per page as defined by `per_page` (default is 10). + - If a `page` parameter is provided, the response will be paginated, showing a specific number of sensors per page as defined by `per-page` (default is 10). - If a search 'filter' is provided, the response will return only sensors where a search term is either present in their name or is a prefix of their ID. The response schema for pagination is inspired by https://datatables.net/manual/server-side#Returned-data security: @@ -1143,9 +1143,9 @@ def auditlog( summary: Get history of asset related actions. description: | The endpoint is paginated and supports search filters. - - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10). - - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10). - - If `sort_by` (field name) and `sort_dir` ("asc" or "desc") are provided, the list will be sorted. + - If the `page` parameter is not provided, all audit logs are returned paginated by `per-page` (legacy alias: `per_page`, default is 10). + - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per-page` (default is 10). + - If `sort-by` (field name, legacy alias: `sort_by`) and `sort-dir` ("asc" or "desc", legacy alias: `sort_dir`) are provided, the list will be sorted. - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name. The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side) security: diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index c1ba9bed83..a1becd67b9 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -352,13 +352,13 @@ def index( Finally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant. This is only possible if the user has the role of a consultant. - Only admins can use this endpoint to fetch sensors from a different account (by using the `account_id` query parameter). + Only admins can use this endpoint to fetch sensors from a different account (by using the `account` query parameter, legacy alias: `account_id`). The `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix. The `unit` parameter allows you to filter by unit. - For the pagination of the sensor list, you can use the `page` and `per_page` query parameters, the `page` parameter is used to trigger - pagination, and the `per_page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per_page` is 10. + For the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`), the `page` parameter is used to trigger + pagination, and the `per-page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per-page` is 10. security: - ApiKeyAuth: [] diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index c9959d0556..d5760d61c1 100644 --- a/flexmeasures/api/v3_0/users.py +++ b/flexmeasures/api/v3_0/users.py @@ -106,12 +106,12 @@ def index( description: | This endpoint returns all accessible users. By default, only active users are returned. - The `account_id` query parameter can be used to filter the users of + The `account` query parameter (legacy alias: `account_id`) can be used to filter the users of a given account. The `include_inactive` query parameter can be used to also fetch inactive users. Accessible users are users in the same account as the current user. - Only admins can use this endpoint to fetch users from a different account (by using the `account_id` query parameter). + Only admins can use this endpoint to fetch users from a different account (by using the `account` query parameter). security: - ApiKeyAuth: [] parameters: @@ -574,9 +574,9 @@ def auditlog( summary: Get history of user actions. description: | The endpoint is paginated and supports search filters. - - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10). - - If a `page` parameter is provided, the response will be paginated, showing a specific number of audit logs per page as defined by `per_page` (default is 10). - - If `sort_by` (field name) and `sort_dir` ("asc" or "desc") are provided, the list will be sorted. + - If the `page` parameter is not provided, all audit logs are returned paginated by `per-page` (legacy alias: `per_page`, default is 10). + - If a `page` parameter is provided, the response will be paginated, showing a specific number of audit logs per page as defined by `per-page` (default is 10). + - If `sort-by` (field name, legacy alias: `sort_by`) and `sort-dir` ("asc" or "desc", legacy alias: `sort_dir`) are provided, the list will be sorted. - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name. The response schema for pagination is inspired by https://datatables.net/manual/server-side parameters: diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index b30c0c7ab4..9455ee6ccb 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1010,7 +1010,7 @@ "/api/v3_0/sensors": { "get": { "summary": "Get list of sensors", - "description": "This endpoint returns all accessible sensors.\nBy default, \"accessible sensors\" means all sensors in the same account as the current user (if they have read permission to the account).\n\nYou can also specify an `account` (an ID parameter), if the user has read access to that account. In this case, all assets under the\nspecified account will be retrieved, and the sensors associated with these assets will be returned.\n\nAlternatively, you can filter by asset hierarchy by providing the `asset` parameter (ID). When this is set, all sensors on the specified\nasset and its sub-assets are retrieved, provided the user has read access to the asset.\n\n> Note: You can't set both account and asset at the same time, you can only have one set. The only exception is if the asset being specified is\n> part of the account that was set, then we allow to see sensors under that asset but then ignore the account (account = None).\n\nFinally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant.\nThis is only possible if the user has the role of a consultant.\n\nOnly admins can use this endpoint to fetch sensors from a different account (by using the `account_id` query parameter).\n\nThe `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix.\nThe `unit` parameter allows you to filter by unit.\n\nFor the pagination of the sensor list, you can use the `page` and `per_page` query parameters, the `page` parameter is used to trigger\npagination, and the `per_page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per_page` is 10.\n", + "description": "This endpoint returns all accessible sensors.\nBy default, \"accessible sensors\" means all sensors in the same account as the current user (if they have read permission to the account).\n\nYou can also specify an `account` (an ID parameter), if the user has read access to that account. In this case, all assets under the\nspecified account will be retrieved, and the sensors associated with these assets will be returned.\n\nAlternatively, you can filter by asset hierarchy by providing the `asset` parameter (ID). When this is set, all sensors on the specified\nasset and its sub-assets are retrieved, provided the user has read access to the asset.\n\n> Note: You can't set both account and asset at the same time, you can only have one set. The only exception is if the asset being specified is\n> part of the account that was set, then we allow to see sensors under that asset but then ignore the account (account = None).\n\nFinally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant.\nThis is only possible if the user has the role of a consultant.\n\nOnly admins can use this endpoint to fetch sensors from a different account (by using the `account` query parameter, legacy alias: `account_id`).\n\nThe `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix.\nThe `unit` parameter allows you to filter by unit.\n\nFor the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`), the `page` parameter is used to trigger\npagination, and the `per-page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per-page` is 10.\n", "security": [ { "ApiKeyAuth": [] @@ -1885,7 +1885,7 @@ "/api/v3_0/accounts": { "get": { "summary": "List all accounts accessible to the current user.", - "description": "This endpoint returns all accounts the current user has access to.\nAccessible accounts include the user's own account, accounts for which the user is a consultant, and all accounts if the user has admin privileges.\n\nThe endpoint supports pagination of the account list using the `page` and `per_page` query parameters.\n - If the `page` parameter is not provided, all accounts are returned, without pagination information. The result will be a list of accounts.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of accounts per page as defined by `per_page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will filter out accounts where each search term is either present in their name.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n", + "description": "This endpoint returns all accounts the current user has access to.\nAccessible accounts include the user's own account, accounts for which the user is a consultant, and all accounts if the user has admin privileges.\n\nThe endpoint supports pagination of the account list using the `page` and `per-page` (legacy alias: `per_page`) query parameters.\n - If the `page` parameter is not provided, all accounts are returned, without pagination information. The result will be a list of accounts.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of accounts per page as defined by `per-page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will filter out accounts where each search term is either present in their name.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n", "security": [ { "ApiKeyAuth": [] @@ -2113,7 +2113,7 @@ "/api/v3_0/users/{id}/auditlog": { "get": { "summary": "Get history of user actions.", - "description": "The endpoint is paginated and supports search filters.\n - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10).\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of audit logs per page as defined by `per_page` (default is 10).\n - If `sort_by` (field name) and `sort_dir` (\"asc\" or \"desc\") are provided, the list will be sorted.\n - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.\n The response schema for pagination is inspired by https://datatables.net/manual/server-side\n", + "description": "The endpoint is paginated and supports search filters.\n - If the `page` parameter is not provided, all audit logs are returned paginated by `per-page` (legacy alias: `per_page`, default is 10).\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of audit logs per page as defined by `per-page` (default is 10).\n - If `sort-by` (field name, legacy alias: `sort_by`) and `sort-dir` (\"asc\" or \"desc\", legacy alias: `sort_dir`) are provided, the list will be sorted.\n - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.\n The response schema for pagination is inspired by https://datatables.net/manual/server-side\n", "parameters": [ { "in": "path", @@ -2383,7 +2383,7 @@ "/api/v3_0/users": { "get": { "summary": "List users.", - "description": "This endpoint returns all accessible users.\nBy default, only active users are returned.\nThe `account_id` query parameter can be used to filter the users of\na given account.\nThe `include_inactive` query parameter can be used to also fetch\ninactive users.\nAccessible users are users in the same account as the current user.\nOnly admins can use this endpoint to fetch users from a different account (by using the `account_id` query parameter).\n", + "description": "This endpoint returns all accessible users.\nBy default, only active users are returned.\nThe `account` query parameter (legacy alias: `account_id`) can be used to filter the users of\na given account.\nThe `include_inactive` query parameter can be used to also fetch\ninactive users.\nAccessible users are users in the same account as the current user.\nOnly admins can use this endpoint to fetch users from a different account (by using the `account` query parameter).\n", "security": [ { "ApiKeyAuth": [] @@ -2640,7 +2640,7 @@ "/api/v3_0/assets/{id}/sensors": { "get": { "summary": "Return all sensors under an asset.", - "description": "This endpoint returns all sensors under an asset.\n\nThe endpoint supports pagination of the sensor list using the `page` and `per_page` query parameters.\n\n- If the `page` parameter is not provided, all sensors are returned, without pagination information. The result will be a list of sensors.\n- If a `page` parameter is provided, the response will be paginated, showing a specific number of sensors per page as defined by `per_page` (default is 10).\n- If a search 'filter' is provided, the response will return only sensors where a search term is either present in their name or is a prefix of their ID.\nThe response schema for pagination is inspired by https://datatables.net/manual/server-side#Returned-data\n", + "description": "This endpoint returns all sensors under an asset.\n\nThe endpoint supports pagination of the sensor list using the `page` and `per-page` (legacy alias: `per_page`) query parameters.\n\n- If the `page` parameter is not provided, all sensors are returned, without pagination information. The result will be a list of sensors.\n- If a `page` parameter is provided, the response will be paginated, showing a specific number of sensors per page as defined by `per-page` (default is 10).\n- If a search 'filter' is provided, the response will return only sensors where a search term is either present in their name or is a prefix of their ID.\nThe response schema for pagination is inspired by https://datatables.net/manual/server-side#Returned-data\n", "security": [ { "ApiKeyAuth": [] @@ -2775,7 +2775,7 @@ "/api/v3_0/assets/{id}/auditlog": { "get": { "summary": "Get history of asset related actions.", - "description": "The endpoint is paginated and supports search filters.\n - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10).\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).\n - If `sort_by` (field name) and `sort_dir` (\"asc\" or \"desc\") are provided, the list will be sorted.\n - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side)\n", + "description": "The endpoint is paginated and supports search filters.\n - If the `page` parameter is not provided, all audit logs are returned paginated by `per-page` (legacy alias: `per_page`, default is 10).\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per-page` (default is 10).\n - If `sort-by` (field name, legacy alias: `sort_by`) and `sort-dir` (\"asc\" or \"desc\", legacy alias: `sort_dir`) are provided, the list will be sorted.\n - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side)\n", "security": [ { "ApiKeyAuth": [] @@ -3616,7 +3616,7 @@ "/api/v3_0/assets": { "get": { "summary": "List assets accessible by the user.", - "description": "This endpoint returns all assets that are accessible by the user after applying optional filters.\n\n - The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used.\n - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`.\n - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`.\n - The `asset_type` query parameter can be used to filter by generic asset type ID.\n - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself).\n - The `depth` query parameter can be used to search only a max number of descendant generations from the root.\n\nThe endpoint supports pagination of the asset list using the `page` and `per_page` query parameters.\n - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will return only assets where each search term is either present in their name or account name, or is a prefix of their ID.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n\nPer default, the response only includes a limited set of asset fields (id, name, account_id, generic_asset_type).\nYou can use the `fields` query parameter to specify a custom set of fields to include in the response.\n", + "description": "This endpoint returns all assets that are accessible by the user after applying optional filters.\n\n - The `account` query parameter (legacy alias: `account_id`) can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used.\n - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`.\n - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`.\n - The `asset_type` query parameter can be used to filter by generic asset type ID.\n - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself).\n - The `depth` query parameter can be used to search only a max number of descendant generations from the root.\n\nThe endpoint supports pagination of the asset list using the `page` and `per-page` (legacy alias: `per_page`) query parameters.\n - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per-page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will return only assets where each search term is either present in their name or account name, or is a prefix of their ID.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n\nPer default, the response only includes a limited set of asset fields (id, name, account_id, generic_asset_type).\nYou can use the `fields` query parameter to specify a custom set of fields to include in the response.\n", "security": [ { "ApiKeyAuth": [] From 661bc34527e14e4f3371ae1c1082dedc95b18ceb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 17:05:52 +0200 Subject: [PATCH 7/7] Address Copilot: fix misleading page-default docs on GET /sensors The docstring claimed the default page value is 1, but the implementation only paginates when page is explicitly provided (page is None -> full, unpaginated list; see the `if page is None` branches below in get_sensors). Reworded to describe the actual omit-page vs. provide-page behavior, matching how the other list endpoints (assets/users/accounts) already describe it. Co-Authored-By: Claude Sonnet 5 Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 5 +++-- flexmeasures/ui/static/openapi-specs.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index a1becd67b9..37fd0539b0 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -357,8 +357,9 @@ def index( The `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix. The `unit` parameter allows you to filter by unit. - For the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`), the `page` parameter is used to trigger - pagination, and the `per-page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per-page` is 10. + For the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`). If `page` is omitted, + all accessible sensors are returned, without pagination. If `page` is provided, the response is paginated, using `per-page` (default 10) to + specify the number of records per page. security: - ApiKeyAuth: [] diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 9455ee6ccb..4ec64ec97a 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1010,7 +1010,7 @@ "/api/v3_0/sensors": { "get": { "summary": "Get list of sensors", - "description": "This endpoint returns all accessible sensors.\nBy default, \"accessible sensors\" means all sensors in the same account as the current user (if they have read permission to the account).\n\nYou can also specify an `account` (an ID parameter), if the user has read access to that account. In this case, all assets under the\nspecified account will be retrieved, and the sensors associated with these assets will be returned.\n\nAlternatively, you can filter by asset hierarchy by providing the `asset` parameter (ID). When this is set, all sensors on the specified\nasset and its sub-assets are retrieved, provided the user has read access to the asset.\n\n> Note: You can't set both account and asset at the same time, you can only have one set. The only exception is if the asset being specified is\n> part of the account that was set, then we allow to see sensors under that asset but then ignore the account (account = None).\n\nFinally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant.\nThis is only possible if the user has the role of a consultant.\n\nOnly admins can use this endpoint to fetch sensors from a different account (by using the `account` query parameter, legacy alias: `account_id`).\n\nThe `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix.\nThe `unit` parameter allows you to filter by unit.\n\nFor the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`), the `page` parameter is used to trigger\npagination, and the `per-page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per-page` is 10.\n", + "description": "This endpoint returns all accessible sensors.\nBy default, \"accessible sensors\" means all sensors in the same account as the current user (if they have read permission to the account).\n\nYou can also specify an `account` (an ID parameter), if the user has read access to that account. In this case, all assets under the\nspecified account will be retrieved, and the sensors associated with these assets will be returned.\n\nAlternatively, you can filter by asset hierarchy by providing the `asset` parameter (ID). When this is set, all sensors on the specified\nasset and its sub-assets are retrieved, provided the user has read access to the asset.\n\n> Note: You can't set both account and asset at the same time, you can only have one set. The only exception is if the asset being specified is\n> part of the account that was set, then we allow to see sensors under that asset but then ignore the account (account = None).\n\nFinally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant.\nThis is only possible if the user has the role of a consultant.\n\nOnly admins can use this endpoint to fetch sensors from a different account (by using the `account` query parameter, legacy alias: `account_id`).\n\nThe `filter` parameter allows you to search for sensors by name, account name, asset name, or sensor ID prefix.\nThe `unit` parameter allows you to filter by unit.\n\nFor the pagination of the sensor list, you can use the `page` and `per-page` query parameters (legacy alias: `per_page`). If `page` is omitted,\nall accessible sensors are returned, without pagination. If `page` is provided, the response is paginated, using `per-page` (default 10) to\nspecify the number of records per page.\n", "security": [ { "ApiKeyAuth": []