diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 7a8306e100..896c09c1c1 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 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/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/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 08eea41154..11b99c1d49 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): @@ -153,6 +170,7 @@ class AssetChartDataKwargsSchema(Schema): class AssetAuditLogPaginationSchema(PaginationSchema): sort_by = fields.Str( + data_key="sort-by", required=False, validate=validate.OneOf(["event_datetime"]), ) @@ -298,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) @@ -455,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: @@ -1125,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: @@ -1427,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/sensors.py b/flexmeasures/api/v3_0/sensors.py index e28a7f0c4a..37fd0539b0 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" @@ -344,13 +352,14 @@ 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`). 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/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/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index 034f4f2e3f..d5760d61c1 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) @@ -99,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: @@ -567,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/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index bee68ad689..2cebbce3de 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1513,6 +1513,14 @@ class AssetTriggerSchema(Schema): }, ] } + + 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( diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index d3db401fcd..b1913251c1 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,11 @@ 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) + + +# 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. diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 9557eadec0..62ce55b7b6 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -134,3 +134,41 @@ 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 + # 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 + # 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) + return aliased diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 506039e6c2..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_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`). 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": [] @@ -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, @@ -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": [] @@ -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", @@ -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", @@ -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", @@ -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": [] @@ -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" }, @@ -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": [] @@ -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": [ @@ -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": [] @@ -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", @@ -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": [] @@ -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",