diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index a1e4896be..eedc66724 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -5,16 +5,13 @@ 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 +v3.0-11 | 2023-08-02 """""""""""""""""""" - Added REST endpoint for fetching one sensor: `/sensors/` (GET) +- Added REST endpoint for adding a sensor: `/sensors` (POST) +- Added REST endpoint for patching a sensor: `/sensors/` (PATCH) +- Added REST endpoint for deleting a sensor: `/sensors/` (DELETE) v3.0-10 | 2023-06-12 """""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index a0e64671b..63d58f95c 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,7 +18,7 @@ New features * Users on FlexMeasures servers in play mode (``FLEXMEASURES_MODE = "play"``) can use the ``sensors_to_show`` attribute to show any sensor on their asset pages, rather than only sensors registered to assets in their own account or to public assets [see `PR #740 `_] * 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 endpoints `/sensors/` for fetching a single sensor and `/sensors` (POST) for adding a sensor. [see `PR #759 `_] and [see `PR #767 `_] +* Added API endpoints `/sensors/` for fetching a single sensor, `/sensors` (POST) for adding a sensor, `/sensors/` (PATCH) for updating a sensor and `/sensors/` (DELETE) for deleting a sensor. [see `PR #759 `_] and [see `PR #767 `_] and [see `PR #773 `_] and [see `PR #784 `_] * The CLI now allows to set lists and dicts as asset & sensor attributes (formerly only single values) [see `PR #762 `_] * Add `ProcessScheduler` class to optimize the starting time of processes one of the policies developed (INFLEXIBLE, SHIFTABLE and BREAKABLE), accessible via the CLI command `flexmeasures add schedule for-process` [see `PR #729 `_ and `PR #768 `_] diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index dacc25d69..c9e3ecd62 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1,5 +1,6 @@ from __future__ import annotations + from datetime import datetime, timedelta from flask import current_app @@ -31,7 +32,7 @@ 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.models.time_series import Sensor, TimedBelief from flexmeasures.data.queries.utils import simplify_index from flexmeasures.data.schemas.sensors import SensorSchema, SensorIdField from flexmeasures.data.schemas.times import AwareDateTimeField, PlanningDurationField @@ -48,6 +49,7 @@ post_sensor_schema = PostSensorDataSchema() sensors_schema = SensorSchema(many=True) sensor_schema = SensorSchema() +partial_sensor_schema = SensorSchema(partial=True, exclude=["generic_asset_id"]) class SensorAPI(FlaskView): @@ -85,11 +87,12 @@ def index(self, account: Account): [ { "entity_address": "ea1.2021-01.io.flexmeasures.company:fm1.42", - "event_resolution": 15, + "event_resolution": PT15M, "generic_asset_id": 1, "name": "Gas demand", "timezone": "Europe/Amsterdam", "unit": "m\u00b3/h" + "id": 2 } ] @@ -519,6 +522,7 @@ def fetch_one(self, id, sensor): "event_resolution": "PT10M", "generic_asset_id": 4, "timezone": "UTC", + "id": 2 } :reqheader Authorization: The authentication token @@ -560,8 +564,21 @@ def post(self, sensor_data: dict): "generic_asset_id": 1, } + **Example response** - The newly posted sensor is returned in the response. + The whole sensor is returned in the response: + + .. sourcecode:: json + + { + "name": "power", + "unit": "kWh", + "entity_address": "ea1.2023-08.localhost:fm1.1", + "event_resolution": "PT1H", + "generic_asset_id": 1, + "timezone": "UTC", + "id": 2 + } :reqheader Authorization: The authentication token :reqheader Content-Type: application/json @@ -576,3 +593,90 @@ def post(self, sensor_data: dict): db.session.add(sensor) db.session.commit() return sensor_schema.dump(sensor), 201 + + @route("/", methods=["PATCH"]) + @use_args(partial_sensor_schema) + @use_kwargs({"db_sensor": SensorIdField(data_key="id")}, location="path") + @permission_required_for_context("update", ctx_arg_name="db_sensor") + @as_json + def patch(self, sensor_data: dict, id: int, db_sensor: Sensor): + """Update a sensor given its identifier. + + .. :quickref: Sensor; Update a sensor + + This endpoint sets data for an existing sensor. + Any subset of sensor fields can be sent. + + The following fields are not allowed to be updated: + - id + - generic_asset_id + - entity_address + + **Example request** + + .. sourcecode:: json + + { + "name": "POWER", + } + + **Example response** + + The whole sensor is returned in the response: + + .. sourcecode:: json + + { + "name": "some gas sensor", + "unit": "m³/h", + "entity_address": "ea1.2023-08.localhost:fm1.1", + "event_resolution": "PT10M", + "generic_asset_id": 4, + "timezone": "UTC", + "id": 2 + } + + :reqheader Authorization: The authentication token + :reqheader Content-Type: application/json + :resheader Content-Type: application/json + :status 200: UPDATED + :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS + :status 401: UNAUTHORIZED + :status 403: INVALID_SENDER + :status 422: UNPROCESSABLE_ENTITY + """ + for k, v in sensor_data.items(): + setattr(db_sensor, k, v) + db.session.add(db_sensor) + db.session.commit() + return sensor_schema.dump(db_sensor), 200 + + @route("/", methods=["DELETE"]) + @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path") + @permission_required_for_context("delete", ctx_arg_name="sensor") + @as_json + def delete(self, id: int, sensor: Sensor): + """Delete a sensor given its identifier. + + .. :quickref: Sensor; Delete a sensor + + This endpoint deletes an existing sensor, as well as all measurements recorded for it. + + :reqheader Authorization: The authentication token + :reqheader Content-Type: application/json + :resheader Content-Type: application/json + :status 204: DELETED + :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS + :status 401: UNAUTHORIZED + :status 403: INVALID_SENDER + :status 422: UNPROCESSABLE_ENTITY + """ + + """Delete time series data.""" + TimedBelief.query.filter(TimedBelief.sensor_id == sensor.id).delete() + + sensor_name = sensor.name + db.session.delete(sensor) + db.session.commit() + current_app.logger.info("Deleted sensor '%s'." % sensor_name) + return {}, 204 diff --git a/flexmeasures/api/v3_0/tests/test_sensors_api.py b/flexmeasures/api/v3_0/tests/test_sensors_api.py index cd7b8e4d2..03276b0ee 100644 --- a/flexmeasures/api/v3_0/tests/test_sensors_api.py +++ b/flexmeasures/api/v3_0/tests/test_sensors_api.py @@ -3,7 +3,7 @@ import pytest from flask import url_for - +from flexmeasures.data.models.time_series import TimedBelief 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 @@ -106,3 +106,94 @@ def test_post_sensor_to_asset_from_unrelated_account(client, setup_api_test_data == "You cannot be authorized for this content or functionality." ) assert response.json["status"] == "INVALID_SENDER" + + +def test_patch_sensor(client, setup_api_test_data): + auth_token = get_auth_token(client, "test_admin_user@seita.nl", "testtest") + sensor = Sensor.query.filter(Sensor.name == "some gas sensor").one_or_none() + + response = client.patch( + url_for("SensorAPI:patch", id=sensor.id), + headers={"content-type": "application/json", "Authorization": auth_token}, + json={ + "name": "Changed name", + }, + ) + assert response.json["name"] == "Changed name" + new_sensor = Sensor.query.filter(Sensor.name == "Changed name").one_or_none() + assert new_sensor.name == "Changed name" + assert Sensor.query.filter(Sensor.name == "some gas sensor").one_or_none() is None + + +@pytest.mark.parametrize( + "attribute, value", + [ + ("generic_asset_id", 8), + ("entity_address", "ea1.2025-01.io.flexmeasures:fm1.1"), + ("id", 7), + ], +) +def test_patch_sensor_for_excluded_attribute( + client, setup_api_test_data, attribute, value +): + """Test to change the generic_asset_id that should not be allowed. + The generic_asset_id is excluded in the partial_sensor_schema""" + auth_token = get_auth_token(client, "test_admin_user@seita.nl", "testtest") + sensor = Sensor.query.filter(Sensor.name == "some temperature sensor").one_or_none() + + response = client.patch( + url_for("SensorAPI:patch", id=sensor.id), + headers={"content-type": "application/json", "Authorization": auth_token}, + json={ + attribute: value, + }, + ) + + print(response.json) + assert response.status_code == 422 + assert response.json["status"] == "UNPROCESSABLE_ENTITY" + assert response.json["message"]["json"][attribute] == ["Unknown field."] + + +def test_patch_sensor_from_unrelated_account(client, setup_api_test_data): + """Try to change the name of a sensor that is in an account the user does not + have access to""" + headers = make_headers_for("test_prosumer_user_2@seita.nl", client) + + sensor = Sensor.query.filter(Sensor.name == "some temperature sensor").one_or_none() + + response = client.patch( + url_for("SensorAPI:patch", id=sensor.id), + headers=headers, + json={ + "name": "try to change the name", + }, + ) + + assert response.status_code == 403 + assert response.json["status"] == "INVALID_SENDER" + + +def test_delete_a_sensor(client, setup_api_test_data): + + existing_sensor_id = setup_api_test_data["some temperature sensor"].id + headers = make_headers_for("test_admin_user@seita.nl", client) + sensor_data = TimedBelief.query.filter( + TimedBelief.sensor_id == existing_sensor_id + ).all() + sensor_count = len(Sensor.query.all()) + + assert isinstance(sensor_data[0].event_value, float) + + delete_sensor_response = client.delete( + url_for("SensorAPI:delete", id=existing_sensor_id), + headers=headers, + ) + assert delete_sensor_response.status_code == 204 + deleted_sensor = Sensor.query.filter_by(id=existing_sensor_id).one_or_none() + assert deleted_sensor is None + assert ( + TimedBelief.query.filter(TimedBelief.sensor_id == existing_sensor_id).all() + == [] + ) + assert len(Sensor.query.all()) == sensor_count - 1 diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index 5bb7d1421..2826e6413 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -26,6 +26,7 @@ class Meta: model = Asset """ + id = ma.auto_field(dump_only=True) name = ma.auto_field(required=True) unit = ma.auto_field(required=True) timezone = ma.auto_field()