Skip to content

Commit

Permalink
433 post sensor (#767)
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): users services change import back

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

* docs(sensor): remove prints and update times docstrings

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

* docs(sensor): update changelogs

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

* docs(sensor): update change_log date

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

* feat(sensor): changes to duration and event_resolution (untested)

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

* feat(cli): adds support for both int and iso duration string for sensor resulution

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

* feat(sensor): changes times duration and sensor schema

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

* feat(sensor): tests for unauthorized

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

* feat(sensor): tests for unauthorized fetch one

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

* feat(sensor): adds docstrings, changes test function names, changelog changes and removes commented code

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

---------

Signed-off-by: GustaafL <guus@seita.nl>
Signed-off-by: Nicolas Höning <nicolas@seita.nl>
Co-authored-by: Nicolas Höning <nicolas@seita.nl>
  • Loading branch information
GustaafL and nhoening committed Aug 1, 2023
1 parent 1b62bd7 commit d25deb2
Show file tree
Hide file tree
Showing 19 changed files with 300 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .vscode/spellright.dict
Expand Up @@ -257,3 +257,8 @@ dataframe
dataframes
args
docstrings
Auth
ctx_loader
ctx_arg_name
ctx_arg_pos
dataset
6 changes: 6 additions & 0 deletions documentation/api/change_log.rst
Expand Up @@ -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
""""""""""""""""""""

Expand Down
2 changes: 1 addition & 1 deletion documentation/changelog.rst
Expand Up @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <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 endpoint `/sensor/<id>` for fetching a single sensor. [see `PR #759 <https://www.github.com/FlexMeasures/flexmeasures/pull/759>`_]
* 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>`_]
* 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, which optimizes the starting time of processes using one of the following policies: INFLEXIBLE, SHIFTABLE and BREAKABLE [see `PR #729 <https://www.github.com/FlexMeasures/flexmeasures/pull/729>`_]

Expand Down
2 changes: 1 addition & 1 deletion documentation/dev/auth.rst
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions flexmeasures/api/dev/sensors.py
Expand Up @@ -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/<id>/chart
Expand Down Expand Up @@ -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/<id>/chart_data
Expand Down Expand Up @@ -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/<id>/chart_annotations
Expand Down Expand Up @@ -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/<id>
Expand All @@ -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/<id>
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v3_0/accounts.py
Expand Up @@ -70,7 +70,7 @@ def index(self):

@route("/<id>", 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.
Expand Down
14 changes: 7 additions & 7 deletions flexmeasures/api/v3_0/assets.py
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -144,7 +144,7 @@ def post(self, asset_data: dict):

@route("/<id>", 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.
Expand Down Expand Up @@ -180,7 +180,7 @@ def fetch_one(self, id, asset):
@route("/<id>", 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.
Expand Down Expand Up @@ -236,7 +236,7 @@ def patch(self, asset_data: dict, id: int, db_asset: GenericAsset):

@route("/<id>", 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.
Expand Down Expand Up @@ -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/<id>/chart
Expand All @@ -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/<id>/chart_data
Expand Down
51 changes: 49 additions & 2 deletions flexmeasures/api/v3_0/sensors.py
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -498,7 +499,7 @@ def get_schedule(self, sensor: Sensor, job_id: str, duration: timedelta, **kwarg

@route("/<id>", 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.
Expand Down Expand Up @@ -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
77 changes: 76 additions & 1 deletion 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(
Expand All @@ -24,10 +28,81 @@ 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:
headers = {"content-type": "application/json"}
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"
10 changes: 10 additions & 0 deletions flexmeasures/api/v3_0/tests/utils.py
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions flexmeasures/api/v3_0/users.py
Expand Up @@ -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.
Expand Down Expand Up @@ -90,7 +90,7 @@ def index(self, account: Account, include_inactive: bool = False):

@route("/<id>")
@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.
Expand Down Expand Up @@ -128,7 +128,7 @@ def get(self, id: int, user: UserModel):
@route("/<id>", 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.
Expand Down Expand Up @@ -204,7 +204,7 @@ def patch(self, id: int, user: UserModel, **user_data):

@route("/<id>/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.
Expand Down

0 comments on commit d25deb2

Please sign in to comment.