Skip to content

Commit

Permalink
433 patch sensor (#773)
Browse files Browse the repository at this point in the history
* feat(sensors): adds fetch_one sensor endpoint to API

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensors): adds post sensor to API

Signed-off-by: GustaafL <guus@seita.nl>

* post sensor still needs work

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): adds post sensor

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): changes the docstring of the post function

Signed-off-by: GustaafL <guus@seita.nl>

* clearer names for the arguments to permission_required_for_context decorator, especially arg_loader was misleading

Signed-off-by: Nicolas Höning <nicolas@seita.nl>

* one more renaming

Signed-off-by: Nicolas Höning <nicolas@seita.nl>

* expanding possibilities in the require_permission_for_context decorator, for when we only have an AuthModelMixin ID.

Signed-off-by: Nicolas Höning <nicolas@seita.nl>

* feat(sensor): post sensor without schema changes

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): adds patch sensor

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): change importin users services

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): update changelogs and fix import

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): update failing test

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): tests for patching excluded attributes and no auth

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): remove NewDurationField since it's unused

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): removes print statement

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): test if database changed after patch

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): tests for patch response json

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): updates docstrings patch sensor

Signed-off-by: GustaafL <guus@seita.nl>

* tests(sensor): test for updating fields that are not allowed and improved docs

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): updated api changelog typo

Signed-off-by: GustaafL <guus@seita.nl>

* feat(sensor): add id field(dump_only) to schema and response json

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): edit docstrings to include id in example response

Signed-off-by: GustaafL <guus@seita.nl>

* 433 delete sensor (#784)

* feat(sensor): adds delete sensor endpoint

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): updated api changelog

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): updated api changelog whitespace

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): updated api changelog missing /

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): update changelog

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): update changelog typo

Signed-off-by: GustaafL <guus@seita.nl>

* docs(sensor): update changelog missing space

Signed-off-by: GustaafL <guus@seita.nl>

* tests(sensor): check for float instead of exact value

Signed-off-by: GustaafL <guus@seita.nl>

---------

Signed-off-by: GustaafL <guus@seita.nl>
Signed-off-by: GustaafL <41048720+GustaafL@users.noreply.github.com>

---------

Signed-off-by: GustaafL <guus@seita.nl>
Signed-off-by: Nicolas Höning <nicolas@seita.nl>
Signed-off-by: GustaafL <41048720+GustaafL@users.noreply.github.com>
Co-authored-by: Nicolas Höning <nicolas@seita.nl>
  • Loading branch information
GustaafL and nhoening committed Aug 2, 2023
1 parent 67e46f4 commit 74dbbcc
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 12 deletions.
11 changes: 4 additions & 7 deletions documentation/api/change_log.rst
Expand Up @@ -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/<id>` (GET)
- Added REST endpoint for adding a sensor: `/sensors` (POST)
- Added REST endpoint for patching a sensor: `/sensors/<id>` (PATCH)
- Added REST endpoint for deleting a sensor: `/sensors/<id>` (DELETE)

v3.0-10 | 2023-06-12
""""""""""""""""""""
Expand Down
2 changes: 1 addition & 1 deletion documentation/changelog.rst
Expand Up @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/739>`_]
* DataSource table now allows storing arbitrary attributes as a JSON (without content validation), similar to the Sensor and GenericAsset tables [see `PR #750 <https://www.github.com/FlexMeasures/flexmeasures/pull/750>`_]
* Added API endpoints `/sensors/<id>` for fetching a single sensor and `/sensors` (POST) for adding a sensor. [see `PR #759 <https://www.github.com/FlexMeasures/flexmeasures/pull/759>`_] and [see `PR #767 <https://www.github.com/FlexMeasures/flexmeasures/pull/767>`_]
* Added API endpoints `/sensors/<id>` for fetching a single sensor, `/sensors` (POST) for adding a sensor, `/sensors/<id>` (PATCH) for updating a sensor and `/sensors/<id>` (DELETE) for deleting a sensor. [see `PR #759 <https://www.github.com/FlexMeasures/flexmeasures/pull/759>`_] and [see `PR #767 <https://www.github.com/FlexMeasures/flexmeasures/pull/767>`_] and [see `PR #773 <https://www.github.com/FlexMeasures/flexmeasures/pull/773>`_] and [see `PR #784 <https://www.github.com/FlexMeasures/flexmeasures/pull/784>`_]
* The CLI now allows to set lists and dicts as asset & sensor attributes (formerly only single values) [see `PR #762 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/729>`_ and `PR #768 <https://www.github.com/FlexMeasures/flexmeasures/pull/768>`_]

Expand Down
110 changes: 107 additions & 3 deletions flexmeasures/api/v3_0/sensors.py
@@ -1,5 +1,6 @@
from __future__ import annotations


from datetime import datetime, timedelta

from flask import current_app
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
}
]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -576,3 +593,90 @@ def post(self, sensor_data: dict):
db.session.add(sensor)
db.session.commit()
return sensor_schema.dump(sensor), 201

@route("/<id>", 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("/<id>", 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
93 changes: 92 additions & 1 deletion flexmeasures/api/v3_0/tests/test_sensors_api.py
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions flexmeasures/data/schemas/sensors.py
Expand Up @@ -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()
Expand Down

0 comments on commit 74dbbcc

Please sign in to comment.