Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

433 post sensor #767

Merged
merged 22 commits into from Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fa465c8
feat(sensors): adds fetch_one sensor endpoint to API
Jul 5, 2023
415f861
feat(sensors): adds post sensor to API
Jul 5, 2023
4e3f6c1
post sensor still needs work
Jul 18, 2023
714b1a0
feat(sensor): adds post sensor
Jul 20, 2023
058cb41
docs(sensor): changes the docstring of the post function
Jul 20, 2023
5691405
clearer names for the arguments to permission_required_for_context de…
nhoening Jul 20, 2023
ed53575
one more renaming
nhoening Jul 20, 2023
2376148
expanding possibilities in the require_permission_for_context decorat…
nhoening Jul 20, 2023
957c144
feat(sensor): post sensor without schema changes
Jul 24, 2023
3e37e08
feat(sensor): adds patch sensor
Jul 25, 2023
8b3dee8
feat(sensor): users services change import back
Jul 25, 2023
1e364ad
docs(sensor): remove prints and update times docstrings
Jul 25, 2023
e8d5c39
docs(sensor): update changelogs
Jul 25, 2023
8f0a511
docs(sensor): update change_log date
Jul 25, 2023
a997dbe
feat(sensor): changes to duration and event_resolution (untested)
Jul 25, 2023
3c467d4
feat(cli): adds support for both int and iso duration string for sens…
Jul 28, 2023
9f87d30
feat(sensor): changes times duration and sensor schema
Jul 28, 2023
0402fbe
feat(sensor): tests for unauthorized
Jul 31, 2023
e49a94f
feat(sensor): tests for unauthorized fetch one
Jul 31, 2023
a044ddd
feat(sensor): resolve merge conflicts
Jul 31, 2023
c8d475e
Merge branch 'main' into 433-post-sensor
GustaafL Jul 31, 2023
af9915c
feat(sensor): adds docstrings, changes test function names, changelog…
Aug 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
1 change: 1 addition & 0 deletions documentation/changelog.rst
Expand Up @@ -14,6 +14,7 @@ New features
* Allow deleting multiple sensors with a single call to ``flexmeasures delete sensor`` by passing the ``--id`` option multiple times [see `PR #734 <https://www.github.com/FlexMeasures/flexmeasures/pull/734>`_]
* 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>`_]
* Added API endpoint `/sensors/` for adding a sensor (POST). [see `PR #767 <https://www.github.com/FlexMeasures/flexmeasures/pull/767>`_]
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
* 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>`_]
* 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>`_]
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",
"resolution": "PT1H",
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
"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
73 changes: 72 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,77 @@ 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_error(
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
client, setup_api_test_data: dict[str, Sensor], use_auth
):
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):
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
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_from_unauthorized_account(client, setup_api_test_data):
auth_token = get_auth_token(client, "test_supplier_user_4@seita.nl", "testtest")
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
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