From 74dbbcc671d3f57a36d12b2b0fcdceefaab4bab3 Mon Sep 17 00:00:00 2001 From: GustaafL <41048720+GustaafL@users.noreply.github.com> Date: Wed, 2 Aug 2023 23:40:35 +0200 Subject: [PATCH] 433 patch sensor (#773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sensors): adds fetch_one sensor endpoint to API Signed-off-by: GustaafL * feat(sensors): adds post sensor to API Signed-off-by: GustaafL * post sensor still needs work Signed-off-by: GustaafL * feat(sensor): adds post sensor Signed-off-by: GustaafL * docs(sensor): changes the docstring of the post function Signed-off-by: GustaafL * clearer names for the arguments to permission_required_for_context decorator, especially arg_loader was misleading Signed-off-by: Nicolas Höning * one more renaming Signed-off-by: Nicolas Höning * expanding possibilities in the require_permission_for_context decorator, for when we only have an AuthModelMixin ID. Signed-off-by: Nicolas Höning * feat(sensor): post sensor without schema changes Signed-off-by: GustaafL * feat(sensor): adds patch sensor Signed-off-by: GustaafL * feat(sensor): change importin users services Signed-off-by: GustaafL * docs(sensor): update changelogs and fix import Signed-off-by: GustaafL * feat(sensor): update failing test Signed-off-by: GustaafL * feat(sensor): tests for patching excluded attributes and no auth Signed-off-by: GustaafL * feat(sensor): remove NewDurationField since it's unused Signed-off-by: GustaafL * feat(sensor): removes print statement Signed-off-by: GustaafL * feat(sensor): test if database changed after patch Signed-off-by: GustaafL * feat(sensor): tests for patch response json Signed-off-by: GustaafL * docs(sensor): updates docstrings patch sensor Signed-off-by: GustaafL * tests(sensor): test for updating fields that are not allowed and improved docs Signed-off-by: GustaafL * docs(sensor): updated api changelog typo Signed-off-by: GustaafL * feat(sensor): add id field(dump_only) to schema and response json Signed-off-by: GustaafL * docs(sensor): edit docstrings to include id in example response Signed-off-by: GustaafL * 433 delete sensor (#784) * feat(sensor): adds delete sensor endpoint Signed-off-by: GustaafL * docs(sensor): updated api changelog Signed-off-by: GustaafL * docs(sensor): updated api changelog whitespace Signed-off-by: GustaafL * docs(sensor): updated api changelog missing / Signed-off-by: GustaafL * docs(sensor): update changelog Signed-off-by: GustaafL * docs(sensor): update changelog typo Signed-off-by: GustaafL * docs(sensor): update changelog missing space Signed-off-by: GustaafL * tests(sensor): check for float instead of exact value Signed-off-by: GustaafL --------- Signed-off-by: GustaafL Signed-off-by: GustaafL <41048720+GustaafL@users.noreply.github.com> --------- Signed-off-by: GustaafL Signed-off-by: Nicolas Höning Signed-off-by: GustaafL <41048720+GustaafL@users.noreply.github.com> Co-authored-by: Nicolas Höning --- documentation/api/change_log.rst | 11 +- documentation/changelog.rst | 2 +- flexmeasures/api/v3_0/sensors.py | 110 +++++++++++++++++- .../api/v3_0/tests/test_sensors_api.py | 93 ++++++++++++++- flexmeasures/data/schemas/sensors.py | 1 + 5 files changed, 205 insertions(+), 12 deletions(-) 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()