diff --git a/.vscode/spellright.dict b/.vscode/spellright.dict index 0d4c10887..5b95070a8 100644 --- a/.vscode/spellright.dict +++ b/.vscode/spellright.dict @@ -257,3 +257,8 @@ dataframe dataframes args docstrings +Auth +ctx_loader +ctx_arg_name +ctx_arg_pos +dataset diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 0941ab77a..a1e4896be 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -5,6 +5,12 @@ API change log .. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL, allowing developers to upgrade at their own pace. + +v3.0-12 | 2023-07-31 +""""""""""""""""""" + +- Added REST endpoint for adding a sensor: `/sensors` (POST) + v3.0-11 | 2023-07-20 """""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 363273181..50c29e667 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -15,7 +15,7 @@ New features * Make it a lot easier to read off the color legend on the asset page, especially when showing many sensors, as they will now be ordered from top to bottom in the same order as they appear in the chart (as defined in the ``sensors_to_show`` attribute), rather than alphabetically [see `PR #742 `_] * Having percentages within the [0, 100] domain is such a common use case that we now always include it in sensor charts with % units, making it easier to read off individual charts and also to compare across charts [see `PR #739 `_] * DataSource table now allows storing arbitrary attributes as a JSON (without content validation), similar to the Sensor and GenericAsset tables [see `PR #750 `_] -* Added API endpoint `/sensor/` for fetching a single sensor. [see `PR #759 `_] +* Added API endpoints `/sensors/` for fetching a single sensor and `/sensors` (POST) for adding a sensor. [see `PR #759 `_] and [see `PR #767 `_] * The CLI now allows to set lists and dicts as asset & sensor attributes (formerly only single values) [see `PR #762 `_] * Add `ProcessScheduler` class, which optimizes the starting time of processes using one of the following policies: INFLEXIBLE, SHIFTABLE and BREAKABLE [see `PR #729 `_] diff --git a/documentation/dev/auth.rst b/documentation/dev/auth.rst index aeb4415da..4f8bb86d4 100644 --- a/documentation/dev/auth.rst +++ b/documentation/dev/auth.rst @@ -30,7 +30,7 @@ You, as the endpoint author, need to make sure this is checked. Here is an examp {"the_resource": ResourceIdField(data_key="resource_id")}, location="path", ) - @permission_required_for_context("read", arg_name="the_resource") + @permission_required_for_context("read", ctx_arg_name="the_resource") @as_json def view(resource_id: int, resource: Resource): return dict(name=resource.name) diff --git a/flexmeasures/api/dev/sensors.py b/flexmeasures/api/dev/sensors.py index 346ebfca9..a31465f46 100644 --- a/flexmeasures/api/dev/sensors.py +++ b/flexmeasures/api/dev/sensors.py @@ -50,7 +50,7 @@ class SensorAPI(FlaskView): }, location="query", ) - @permission_required_for_context("read", arg_name="sensor") + @permission_required_for_context("read", ctx_arg_name="sensor") def get_chart(self, id: int, sensor: Sensor, **kwargs): """GET from /sensor//chart @@ -85,7 +85,7 @@ def get_chart(self, id: int, sensor: Sensor, **kwargs): }, location="query", ) - @permission_required_for_context("read", arg_name="sensor") + @permission_required_for_context("read", ctx_arg_name="sensor") def get_chart_data(self, id: int, sensor: Sensor, **kwargs): """GET from /sensor//chart_data @@ -118,7 +118,7 @@ def get_chart_data(self, id: int, sensor: Sensor, **kwargs): }, location="query", ) - @permission_required_for_context("read", arg_name="sensor") + @permission_required_for_context("read", ctx_arg_name="sensor") def get_chart_annotations(self, id: int, sensor: Sensor, **kwargs): """GET from /sensor//chart_annotations @@ -147,7 +147,7 @@ def get_chart_annotations(self, id: int, sensor: Sensor, **kwargs): {"sensor": SensorIdField(data_key="id")}, location="path", ) - @permission_required_for_context("read", arg_name="sensor") + @permission_required_for_context("read", ctx_arg_name="sensor") def get(self, id: int, sensor: Sensor): """GET from /sensor/ @@ -170,7 +170,7 @@ class AssetAPI(FlaskView): {"asset": AssetIdField(data_key="id")}, location="path", ) - @permission_required_for_context("read", arg_name="asset") + @permission_required_for_context("read", ctx_arg_name="asset") def get(self, id: int, asset: GenericAsset): """GET from /asset/ diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 724332d39..21da1ff4b 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -70,7 +70,7 @@ def index(self): @route("/", methods=["GET"]) @use_kwargs({"account": AccountIdField(data_key="id")}, location="path") - @permission_required_for_context("read", arg_name="account") + @permission_required_for_context("read", ctx_arg_name="account") @as_json def get(self, id: int, account: Account): """API endpoint to get an account. diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index db505e34a..ec240f03a 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -42,7 +42,7 @@ class AssetAPI(FlaskView): }, location="query", ) - @permission_required_for_context("read", arg_name="account") + @permission_required_for_context("read", ctx_arg_name="account") @as_json def index(self, account: Account): """List all assets owned by a certain account. @@ -103,7 +103,7 @@ def public(self): @route("", methods=["POST"]) @permission_required_for_context( - "create-children", arg_loader=AccountIdField.load_current + "create-children", ctx_loader=AccountIdField.load_current ) @use_args(asset_schema) def post(self, asset_data: dict): @@ -144,7 +144,7 @@ def post(self, asset_data: dict): @route("/", methods=["GET"]) @use_kwargs({"asset": AssetIdField(data_key="id")}, location="path") - @permission_required_for_context("read", arg_name="asset") + @permission_required_for_context("read", ctx_arg_name="asset") @as_json def fetch_one(self, id, asset): """Fetch a given asset. @@ -180,7 +180,7 @@ def fetch_one(self, id, asset): @route("/", methods=["PATCH"]) @use_args(partial_asset_schema) @use_kwargs({"db_asset": AssetIdField(data_key="id")}, location="path") - @permission_required_for_context("update", arg_name="db_asset") + @permission_required_for_context("update", ctx_arg_name="db_asset") @as_json def patch(self, asset_data: dict, id: int, db_asset: GenericAsset): """Update an asset given its identifier. @@ -236,7 +236,7 @@ def patch(self, asset_data: dict, id: int, db_asset: GenericAsset): @route("/", methods=["DELETE"]) @use_kwargs({"asset": AssetIdField(data_key="id")}, location="path") - @permission_required_for_context("delete", arg_name="asset") + @permission_required_for_context("delete", ctx_arg_name="asset") @as_json def delete(self, id: int, asset: GenericAsset): """Delete an asset given its identifier. @@ -278,7 +278,7 @@ def delete(self, id: int, asset: GenericAsset): }, location="query", ) - @permission_required_for_context("read", arg_name="asset") + @permission_required_for_context("read", ctx_arg_name="asset") def get_chart(self, id: int, asset: GenericAsset, **kwargs): """GET from /assets//chart @@ -302,7 +302,7 @@ def get_chart(self, id: int, asset: GenericAsset, **kwargs): }, location="query", ) - @permission_required_for_context("read", arg_name="asset") + @permission_required_for_context("read", ctx_arg_name="asset") def get_chart_data(self, id: int, asset: GenericAsset, **kwargs): """GET from /assets//chart_data diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index be2164fb8..59f7e5868 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -30,6 +30,7 @@ from flexmeasures.auth.decorators import permission_required_for_context from flexmeasures.data import db from flexmeasures.data.models.user import Account +from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.queries.utils import simplify_index from flexmeasures.data.schemas.sensors import SensorSchema, SensorIdField @@ -64,7 +65,7 @@ class SensorAPI(FlaskView): }, location="query", ) - @permission_required_for_context("read", arg_name="account") + @permission_required_for_context("read", ctx_arg_name="account") @as_json def index(self, account: Account): """API endpoint to list all sensors of an account. @@ -498,7 +499,7 @@ def get_schedule(self, sensor: Sensor, job_id: str, duration: timedelta, **kwarg @route("/", methods=["GET"]) @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path") - @permission_required_for_context("read", arg_name="sensor") + @permission_required_for_context("read", ctx_arg_name="sensor") @as_json def fetch_one(self, id, sensor): """Fetch a given sensor. @@ -529,4 +530,50 @@ def fetch_one(self, id, sensor): :status 403: INVALID_SENDER :status 422: UNPROCESSABLE_ENTITY """ + + sensor.resolution = sensor.event_resolution return sensor_schema.dump(sensor), 200 + + @route("", methods=["POST"]) + @use_args(sensor_schema) + @permission_required_for_context( + "create-children", + ctx_arg_pos=1, + ctx_arg_name="generic_asset_id", + ctx_loader=GenericAsset, + pass_ctx_to_loader=True, + ) + def post(self, sensor_data: dict): + """Create new asset. + + .. :quickref: Sensor; Create a new Sensor + + This endpoint creates a new Sensor. + + **Example request** + + .. sourcecode:: json + + { + "name": "power", + "event_resolution": "PT1H", + "unit": "kWh", + "generic_asset_id": 1, + } + + + The newly posted sensor is returned in the response. + + :reqheader Authorization: The authentication token + :reqheader Content-Type: application/json + :resheader Content-Type: application/json + :status 201: CREATED + :status 400: INVALID_REQUEST + :status 401: UNAUTHORIZED + :status 403: INVALID_SENDER + :status 422: UNPROCESSABLE_ENTITY + """ + sensor = Sensor(**sensor_data) + db.session.add(sensor) + db.session.commit() + return sensor_schema.dump(sensor), 201 diff --git a/flexmeasures/api/v3_0/tests/test_sensors_api.py b/flexmeasures/api/v3_0/tests/test_sensors_api.py index b57fcfd83..cd7b8e4d2 100644 --- a/flexmeasures/api/v3_0/tests/test_sensors_api.py +++ b/flexmeasures/api/v3_0/tests/test_sensors_api.py @@ -1,11 +1,15 @@ from __future__ import annotations - +import pytest from flask import url_for from flexmeasures import Sensor from flexmeasures.api.tests.utils import get_auth_token +from flexmeasures.api.v3_0.tests.utils import get_sensor_post_data +from flexmeasures.data.schemas.sensors import SensorSchema + +sensor_schema = SensorSchema() def test_fetch_one_sensor( @@ -24,6 +28,41 @@ def test_fetch_one_sensor( assert response.json["unit"] == "m³/h" assert response.json["generic_asset_id"] == 4 assert response.json["timezone"] == "UTC" + assert response.json["event_resolution"] == "PT10M" + + +@pytest.mark.parametrize("use_auth", [False, True]) +def test_fetch_one_sensor_no_auth( + client, setup_api_test_data: dict[str, Sensor], use_auth +): + """Test 1: Sensor with id 1 is not in the test_prosumer_user_2@seita.nl's account. + The Supplier Account as can be seen in flexmeasures/api/v3_0/tests/conftest.py + Test 2: There is no authentication int the headers""" + sensor_id = 1 + if use_auth: + headers = make_headers_for("test_prosumer_user_2@seita.nl", client) + response = client.get( + url_for("SensorAPI:fetch_one", id=sensor_id), + headers=headers, + ) + assert response.status_code == 403 + assert ( + response.json["message"] + == "You cannot be authorized for this content or functionality." + ) + assert response.json["status"] == "INVALID_SENDER" + else: + headers = make_headers_for(None, client) + response = client.get( + url_for("SensorAPI:fetch_one", id=sensor_id), + headers=headers, + ) + assert response.status_code == 401 + assert ( + response.json["message"] + == "You could not be properly authenticated for this content or functionality." + ) + assert response.json["status"] == "UNAUTHORIZED" def make_headers_for(user_email: str | None, client) -> dict: @@ -31,3 +70,39 @@ def make_headers_for(user_email: str | None, client) -> dict: if user_email: headers["Authorization"] = get_auth_token(client, user_email, "testtest") return headers + + +def test_post_a_sensor(client, setup_api_test_data): + auth_token = get_auth_token(client, "test_admin_user@seita.nl", "testtest") + post_data = get_sensor_post_data() + response = client.post( + url_for("SensorAPI:post"), + json=post_data, + headers={"content-type": "application/json", "Authorization": auth_token}, + ) + print("Server responded with:\n%s" % response.json) + assert response.status_code == 201 + assert response.json["name"] == "power" + assert response.json["event_resolution"] == "PT1H" + + sensor: Sensor = Sensor.query.filter_by(name="power").one_or_none() + assert sensor is not None + assert sensor.unit == "kWh" + + +def test_post_sensor_to_asset_from_unrelated_account(client, setup_api_test_data): + """Tries to add sensor to account the user doesn't have access to""" + auth_token = get_auth_token(client, "test_supplier_user_4@seita.nl", "testtest") + post_data = get_sensor_post_data() + response = client.post( + url_for("SensorAPI:post"), + json=post_data, + headers={"content-type": "application/json", "Authorization": auth_token}, + ) + print("Server responded with:\n%s" % response.json) + assert response.status_code == 403 + assert ( + response.json["message"] + == "You cannot be authorized for this content or functionality." + ) + assert response.json["status"] == "INVALID_SENDER" diff --git a/flexmeasures/api/v3_0/tests/utils.py b/flexmeasures/api/v3_0/tests/utils.py index 41d4e8e5a..4dfa3fe79 100644 --- a/flexmeasures/api/v3_0/tests/utils.py +++ b/flexmeasures/api/v3_0/tests/utils.py @@ -40,6 +40,16 @@ def get_asset_post_data(account_id: int = 1, asset_type_id: int = 1) -> dict: return post_data +def get_sensor_post_data(generic_asset_id: int = 2) -> dict: + post_data = { + "name": "power", + "event_resolution": "PT1H", + "unit": "kWh", + "generic_asset_id": generic_asset_id, + } + return post_data + + def message_for_trigger_schedule( unknown_prices: bool = False, with_targets: bool = False, diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py index d3e165fcf..459439954 100644 --- a/flexmeasures/api/v3_0/users.py +++ b/flexmeasures/api/v3_0/users.py @@ -44,7 +44,7 @@ class UserAPI(FlaskView): }, location="query", ) - @permission_required_for_context("read", arg_name="account") + @permission_required_for_context("read", ctx_arg_name="account") @as_json def index(self, account: Account, include_inactive: bool = False): """API endpoint to list all users of an account. @@ -90,7 +90,7 @@ def index(self, account: Account, include_inactive: bool = False): @route("/") @use_kwargs({"user": UserIdField(data_key="id")}, location="path") - @permission_required_for_context("read", arg_name="user") + @permission_required_for_context("read", ctx_arg_name="user") @as_json def get(self, id: int, user: UserModel): """API endpoint to get a user. @@ -128,7 +128,7 @@ def get(self, id: int, user: UserModel): @route("/", methods=["PATCH"]) @use_kwargs(partial_user_schema) @use_kwargs({"user": UserIdField(data_key="id")}, location="path") - @permission_required_for_context("update", arg_name="user") + @permission_required_for_context("update", ctx_arg_name="user") @as_json def patch(self, id: int, user: UserModel, **user_data): """API endpoint to patch user data. @@ -204,7 +204,7 @@ def patch(self, id: int, user: UserModel, **user_data): @route("//password-reset", methods=["PATCH"]) @use_kwargs({"user": UserIdField(data_key="id")}, location="path") - @permission_required_for_context("update", arg_name="user") + @permission_required_for_context("update", ctx_arg_name="user") @as_json def reset_user_password(self, id: int, user: UserModel): """API endpoint to reset the user's current password, cookies and auth tokens, and to email a password reset link to the user. diff --git a/flexmeasures/auth/decorators.py b/flexmeasures/auth/decorators.py index d2afbdd08..0624a06f6 100644 --- a/flexmeasures/auth/decorators.py +++ b/flexmeasures/auth/decorators.py @@ -106,55 +106,92 @@ def decorated_view(*args, **kwargs): def permission_required_for_context( permission: str, - arg_pos: int | None = None, - arg_name: str | None = None, - arg_loader: Callable | None = None, + ctx_arg_pos: int | None = None, + ctx_arg_name: str | None = None, + ctx_loader: Callable | None = None, + pass_ctx_to_loader: bool = False, ): """ This decorator can be used to make sure that the current user has the necessary permission to access the context. - The context needs to be an AuthModelMixin and is found ... - - by loading it via the arg_loader callable; - - otherwise: - * by the keyword argument arg_name; - * and/or by a position in the non-keyword arguments (arg_pos). - If nothing is passed, the context lookup defaults to arg_pos=0. + The permission needs to be a known permission and is checked with principal descriptions from the context's access control list (see AuthModelMixin.__acl__). + This decorator will first load the context (see below for details) and then call check_access to make sure the current user has the permission. - Using both arg_name and arg_pos arguments is useful when Marshmallow de-serializes to a dict and you are using use_args. In this case, the context lookup applies first arg_pos, then arg_name. + A 403 response is raised if there is no principal for the required permission. + A 401 response is raised if the user is not authenticated at all. - The permission needs to be a known permission and is checked with principal descriptions from the context's access control list (see AuthModelMixin.__acl__). + We will now explain how to load a context, and give an example: + + The context needs to be an AuthModelMixin and is found ... + - by loading it via the ctx_loader callable; + - otherwise: + * by the keyword argument ctx_arg_name; + * and/or by a position in the non-keyword arguments (ctx_arg_pos). + If nothing is passed, the context lookup defaults to ctx_arg_pos=0. - Usually, you'd place a marshmallow field further up in the decorator chain, e.g.: + Let's look at an example. Usually, you'd place a marshmallow field further up in the decorator chain, e.g.: @app.route("/resource/", methods=["GET"]) @use_kwargs( {"the_resource": ResourceIdField(data_key="resource_id")}, location="path", ) - @permission_required_for_context("read", arg_name="the_resource") + @permission_required_for_context("read", ctx_arg_name="the_resource") @as_json def view(resource_id: int, the_resource: Resource): return dict(name=the_resource.name) - Where `ResourceIdField._deserialize()` turns the id parameter into a Resource context (if possible). + Note that in this example, `ResourceIdField._deserialize()` turns the id parameter into a Resource context (if possible). - This decorator raises a 403 response if there is no principal for the required permission. - It raises a 401 response if the user is not authenticated at all. + The ctx_loader: + + The ctx_loader can be a function without arguments or it takes the context loaded from the arguments as input (using pass_ctx_to_loader=True). + A special case is useful when the arguments contain the context ID (not the instance). + Then, the loader can be a subclass of AuthModelMixin, and this decorator will look up the instance. + + Using both arg name and position: + + Using both ctx_arg_name and ctx_arg_pos arguments is useful when Marshmallow de-serializes to a dict and you are using use_args. In this case, the context lookup applies first ctx_arg_pos, then ctx_arg_name. + + Let's look at a slightly more complex example where we combine both special cases from above. + We parse a dictionary from the input with a Marshmallow schema, in which a context ID can be found which we need to instantiate: + + @app.route("/resource", methods=["POST"]) + @use_args(resource_schema) + @permission_required_for_context( + "create-children", ctx_arg_pos=1, ctx_arg_name="resource_id", ctx_loader=Resource, pass_ctx_to_loader=True + ) + def post(self, resource_data: dict): + Note that in this example, resource_data is the input parsed by resource_schema, "resource_id" is one of the parameters in this schema, and Resource is a subclass of AuthModelMixin. """ def wrapper(fn): @wraps(fn) def decorated_view(*args, **kwargs): # load & check context - if arg_loader is not None: - context: AuthModelMixin = arg_loader() - elif arg_pos is not None and arg_name is not None: - context = args[arg_pos][arg_name] - elif arg_pos is not None: - context = args[arg_pos] - elif arg_name is not None: - context = kwargs[arg_name] + context: AuthModelMixin = None + + # first set context_from_args, if possible + context_from_args: AuthModelMixin = None + if ctx_arg_pos is not None and ctx_arg_name is not None: + context_from_args = args[ctx_arg_pos][ctx_arg_name] + elif ctx_arg_pos is not None: + context_from_args = args[ctx_arg_pos] + elif ctx_arg_name is not None: + context_from_args = kwargs[ctx_arg_name] + elif len(args) > 0: + context_from_args = args[0] + + # if a loader is given, use that, otherwise fall back to context_from_args + if ctx_loader is not None: + if pass_ctx_to_loader: + if issubclass(ctx_loader, AuthModelMixin): + context = ctx_loader.query.get(context_from_args) + else: + context = ctx_loader(context_from_args) + else: + context = ctx_loader() else: - context = args[0] + context = context_from_args check_access(context, permission) diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py index 6a2bbaa03..e85cf7f03 100644 --- a/flexmeasures/auth/policy.py +++ b/flexmeasures/auth/policy.py @@ -85,7 +85,7 @@ def check_access(context: AuthModelMixin, permission: str): Raises 401 or 403 otherwise. """ - # check current user + # check permission and current user before taking context into account if permission not in PERMISSIONS: raise Forbidden(f"Permission '{permission}' cannot be handled.") if current_user.is_anonymous: diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index cf31026bf..86c47ded3 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta +import isodate from typing import Type import json from pathlib import Path @@ -200,8 +201,8 @@ def new_user( @click.option( "--event-resolution", required=True, - type=int, - help="Expected resolution of the data in minutes", + type=str, + help="Expected resolution of the data in ISO8601 duration string", ) @click.option( "--timezone", @@ -234,8 +235,18 @@ def add_sensor(**args): ) raise click.Abort() del args["attributes"] # not part of schema + if args["event_resolution"].isdigit(): + click.secho( + "DeprecationWarning: Use ISO8601 duration string for event-resolution, minutes in int will be depricated from v0.16.0", + **MsgStyle.WARN, + ) + timedelta_event_resolution = timedelta(minutes=int(args["event_resolution"])) + isodate_event_resolution = isodate.duration_isoformat( + timedelta_event_resolution + ) + args["event_resolution"] = isodate_event_resolution check_errors(SensorSchema().validate(args)) - args["event_resolution"] = timedelta(minutes=args["event_resolution"]) + sensor = Sensor(**args) if not isinstance(attributes, dict): click.secho("Attributes should be a dict.", **MsgStyle.ERROR) diff --git a/flexmeasures/cli/tests/conftest.py b/flexmeasures/cli/tests/conftest.py index 028012edc..1e502150e 100644 --- a/flexmeasures/cli/tests/conftest.py +++ b/flexmeasures/cli/tests/conftest.py @@ -96,3 +96,22 @@ def reporter_config_raw(app, db, setup_dummy_data): ) return reporter_config_raw + + +@pytest.fixture(scope="module") +@pytest.mark.skip_github +def setup_dummy_asset(db, app): + """ + Create an Asset to add sensors to and return the id. + """ + dummy_asset_type = GenericAssetType(name="DummyGenericAssetType") + + db.session.add(dummy_asset_type) + + dummy_asset = GenericAsset( + name="DummyGenericAsset", generic_asset_type=dummy_asset_type + ) + db.session.add(dummy_asset) + db.session.commit() + + return dummy_asset.id diff --git a/flexmeasures/cli/tests/test_data_add.py b/flexmeasures/cli/tests/test_data_add.py index cb924b47c..7ca6f9e36 100644 --- a/flexmeasures/cli/tests/test_data_add.py +++ b/flexmeasures/cli/tests/test_data_add.py @@ -211,3 +211,33 @@ def test_add_reporter(app, db, setup_dummy_data, reporter_config_raw): ) assert len(stored_report) == 95 + + +@pytest.mark.skip_github +@pytest.mark.parametrize( + "event_resolution, name, success", + [("PT20M", "ONE", True), (15, "TWO", True), ("some_string", "THREE", False)], +) +def test_add_sensor(app, db, setup_dummy_asset, event_resolution, name, success): + from flexmeasures.cli.data_add import add_sensor + + asset = setup_dummy_asset + + runner = app.test_cli_runner() + + cli_input = { + "name": name, + "event-resolution": event_resolution, + "unit": "kWh", + "asset-id": asset, + "timezone": "UTC", + } + runner = app.test_cli_runner() + result = runner.invoke(add_sensor, to_flags(cli_input)) + sensor: Sensor = Sensor.query.filter_by(name=name).one_or_none() + if success: + assert result.exit_code == 0 + sensor.unit == "kWh" + else: + assert result.exit_code == 1 + assert sensor is None diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index 30db92345..5bb7d1421 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -9,6 +9,7 @@ with_appcontext_if_needed, ) from flexmeasures.utils.unit_utils import is_valid_unit +from flexmeasures.data.schemas.times import DurationField class SensorSchemaMixin(Schema): @@ -28,7 +29,7 @@ class Meta: name = ma.auto_field(required=True) unit = ma.auto_field(required=True) timezone = ma.auto_field() - event_resolution = fields.TimeDelta(required=True, precision="minutes") + event_resolution = DurationField(required=True) entity_address = fields.String(dump_only=True) @validates("unit") diff --git a/flexmeasures/data/schemas/tests/test_times.py b/flexmeasures/data/schemas/tests/test_times.py index 16e5dcd68..b33dbbebc 100644 --- a/flexmeasures/data/schemas/tests/test_times.py +++ b/flexmeasures/data/schemas/tests/test_times.py @@ -65,6 +65,7 @@ def test_duration_field_nominal_grounded( ("1H", "Unable to parse duration string"), ("PP1M", "time designator 'T' missing"), ("PT2D", "Unrecognised ISO 8601 date format"), + ("PT40S", "FlexMeasures only support multiples of 1 minute."), ], ) def test_duration_field_invalid(duration_input, error_msg): diff --git a/flexmeasures/data/schemas/times.py b/flexmeasures/data/schemas/times.py index b56d70352..8006eed26 100644 --- a/flexmeasures/data/schemas/times.py +++ b/flexmeasures/data/schemas/times.py @@ -29,11 +29,16 @@ def _deserialize(self, value, attr, obj, **kwargs) -> timedelta | isodate.Durati This method throws a ValidationError if the string is not ISO norm. """ try: - return isodate.parse_duration(value) + duration_value = isodate.parse_duration(value) except ISO8601Error as iso_err: raise DurationValidationError( f"Cannot parse {value} as ISO8601 duration: {iso_err}" ) + if duration_value.seconds % 60 != 0 or duration_value.microseconds != 0: + raise DurationValidationError( + "FlexMeasures only support multiples of 1 minute." + ) + return duration_value def _serialize(self, value, attr, data, **kwargs): """