Skip to content

Commit

Permalink
432 feat(account): account api endpoint and webpages (#605)
Browse files Browse the repository at this point in the history
Added API endpoints and webpages `/accounts` and `/accounts/<id>` to list accounts and show an overview of the assets, users and account roles of an account [see `PR #605 <https://github.com/FlexMeasures/flexmeasures/pull/605>`_]

Various UI changes to make the UI have a consistent style. Showing the pages within the same card div.


* Add user.last_seen to CLI command show account

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

* Document health API

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

* Start /accounts API: GET endpoints

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

* remove two debugging statements

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

* use utility function for headers, fix typo

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

* support new-style Optional shorthand in Python > 3.10

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

* update the number of accounts, as PR 602 added two more

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

* feat(CRUD): add R of accounts CRUD

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

* three textual corrections

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

* get_accounts util function returns empty list if unknown role is passed for filtering

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

* feat(CRUD): updated accounts tests and added links to user pages

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

* feat(CRUD): add accounts to navbar and get single account through api

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

* feat(CRUD): review comments implemented

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

* fix docstring in API /index

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

* chore(changelog): API changelog added accounts endpoints

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

* chore(changelog): changelog added accounts endpoints and pages

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

* chore(changelog): changelog added accounts endpoints and pages

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

* chore(changelog): changelog added accounts endpoints and pages

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

* chore(changelog): PR number corrected

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

* feat(account): accountpage updated with asset and user info

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

* feat(accounts): Add user and asset counts to accounts page

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

* Add account role schema (#625)

* Add AccountRoleSchema and connect it to the AccountSchema

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Update test

Signed-off-by: F.N. Claessen <felix@seita.nl>

---------

Signed-off-by: F.N. Claessen <felix@seita.nl>
Co-authored-by: Flix6x <flix6x@users.noreply.github.com>

* test

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

* feat(accounts): account role names added to the account pages

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

* refactor(users): review changes, updated changelog and typing

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

* refactor(crud): review changes to html templates

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

* Remove redundant lines

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Fix type annotations

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Fix button for including inactive users

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Move button to the side

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Fix data sort on copy of users table

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Add missing decorators to fix redirects to the login page

Signed-off-by: F.N. Claessen <felix@seita.nl>

---------

Signed-off-by: Nicolas Höning <nicolas@seita.nl>
Signed-off-by: GustaafL <guus@seita.nl>
Signed-off-by: GustaafL <41048720+GustaafL@users.noreply.github.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
Co-authored-by: GustaafL <guus@seita.nl>
Co-authored-by: GustaafL <41048720+GustaafL@users.noreply.github.com>
Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Co-authored-by: Flix6x <flix6x@users.noreply.github.com>
Co-authored-by: F.N. Claessen <felix@seita.nl>
  • Loading branch information
6 people committed Apr 17, 2023
1 parent fd62ed3 commit 67d2236
Show file tree
Hide file tree
Showing 27 changed files with 886 additions and 246 deletions.
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-8 | 2023-03-23
"""""""""""""""""""

- Added REST endpoint for listing accounts and their account roles: `/accounts` (GET)
- Added REST endpoint for showing an account and its account roles: `/accounts/<id>` (GET)

v3.0-7 | 2023-02-28
"""""""""""""""""""

Expand Down
4 changes: 2 additions & 2 deletions documentation/api/v3_0.rst
Expand Up @@ -7,14 +7,14 @@ Summary
-------

.. qrefflask:: flexmeasures.app:create(env="documentation")
:modules: flexmeasures.api.v3_0.assets, flexmeasures.api.v3_0.sensors, flexmeasures.api.v3_0.users
:modules: flexmeasures.api.v3_0.assets, flexmeasures.api.v3_0.sensors, flexmeasures.api.v3_0.users, flexmeasures.api.v3_0.health
:order: path
:include-empty-docstring:

API Details
-----------

.. autoflask:: flexmeasures.app:create(env="documentation")
:modules: flexmeasures.api.v3_0.assets, flexmeasures.api.v3_0.sensors, flexmeasures.api.v3_0.users
:modules: flexmeasures.api.v3_0.assets, flexmeasures.api.v3_0.sensors, flexmeasures.api.v3_0.users, flexmeasures.api.v3_0.health
:order: path
:include-empty-docstring:
1 change: 1 addition & 0 deletions documentation/changelog.rst
Expand Up @@ -16,6 +16,7 @@ New features
* Overlay charts (e.g. power profiles) on the asset page using the `sensors_to_show` attribute, and distinguish plots by source (different trace), sensor (different color) and source type (different stroke dash) [see `PR #534 <https://www.github.com/FlexMeasures/flexmeasures/pull/534>`_]
* The ``FLEXMEASURES_MAX_PLANNING_HORIZON`` config setting can also be set as an integer number of planning steps rather than just as a fixed duration, which makes it possible to schedule further ahead in coarser time steps [see `PR #583 <https://www.github.com/FlexMeasures/flexmeasures/pull/583>`_]
* Different text styles for CLI output for errors, warnings or success messages. [see `PR #609 <https://www.github.com/FlexMeasures/flexmeasures/pull/609>`_]
* Added API endpoints and webpages `/accounts` and `/accounts/<id>` to list accounts and show an overview of the assets, users and account roles of an account [see `PR #605 <https://github.com/FlexMeasures/flexmeasures/pull/605>`_]
* Avoid redundantly recomputing jobs that are triggered without a relevant state change. `FLEXMEASURES_JOB_CACHE_TTL` config setting defines the time in which the jobs with the same arguments are not being recomputed. [see `PR #616 <https://www.github.com/FlexMeasures/flexmeasures/pull/616>`_]

Bugfixes
Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/api/v3_0/__init__.py
@@ -1,6 +1,7 @@
from flask import Flask

from flexmeasures.api.v3_0.sensors import SensorAPI
from flexmeasures.api.v3_0.accounts import AccountAPI
from flexmeasures.api.v3_0.users import UserAPI
from flexmeasures.api.v3_0.assets import AssetAPI
from flexmeasures.api.v3_0.health import HealthAPI
Expand All @@ -12,6 +13,7 @@ def register_at(app: Flask):
v3_0_api_prefix = "/api/v3_0"

SensorAPI.register(app, route_prefix=v3_0_api_prefix)
AccountAPI.register(app, route_prefix=v3_0_api_prefix)
UserAPI.register(app, route_prefix=v3_0_api_prefix)
AssetAPI.register(app, route_prefix=v3_0_api_prefix)
HealthAPI.register(app, route_prefix=v3_0_api_prefix)
102 changes: 102 additions & 0 deletions flexmeasures/api/v3_0/accounts.py
@@ -0,0 +1,102 @@
from flask_classful import FlaskView, route
from webargs.flaskparser import use_kwargs
from flask_security import current_user, auth_required
from flask_json import as_json

from flexmeasures.auth.policy import user_has_admin_access
from flexmeasures.auth.decorators import permission_required_for_context
from flexmeasures.data.models.user import Account
from flexmeasures.data.services.accounts import get_accounts
from flexmeasures.api.common.schemas.users import AccountIdField
from flexmeasures.data.schemas.account import AccountSchema

"""
API endpoints to manage accounts.
Both POST (to create) and DELETE are not accessible via the API, but as CLI functions.
Editing (PATCH) is also not yet implemented, but might be next, e.g. for the name or roles.
"""

# Instantiate schemas outside of endpoint logic to minimize response time
account_schema = AccountSchema()
accounts_schema = AccountSchema(many=True)
partial_account_schema = AccountSchema(partial=True)


class AccountAPI(FlaskView):
route_base = "/accounts"
trailing_slash = False

@route("", methods=["GET"])
@auth_required("token", "session")
@as_json
def index(self):
"""API endpoint to list all accounts accessible to the current user.
.. :quickref: Account; Download account list
This endpoint returns all accessible accounts.
Accessible accounts are your own account, or all accounts for admins.
When the super-account concept (GH#203) lands, then users in such accounts see all managed accounts.
**Example response**
An example of one account being returned:
.. sourcecode:: json
[
{
'id': 1,
'name': 'Test Account'
'account_roles': [1, 3],
}
]
:reqheader Authorization: The authentication token
:reqheader Content-Type: application/json
:resheader Content-Type: application/json
:status 200: PROCESSED
:status 400: INVALID_REQUEST
:status 401: UNAUTHORIZED
:status 403: INVALID_SENDER
:status 422: UNPROCESSABLE_ENTITY
"""
if user_has_admin_access(current_user, "read"):
accounts = get_accounts()
else:
accounts = [current_user.account]
return accounts_schema.dump(accounts), 200

@route("/<id>", methods=["GET"])
@use_kwargs({"account": AccountIdField(data_key="id")}, location="path")
@permission_required_for_context("read", arg_name="account")
@as_json
def get(self, id: int, account: Account):
"""API endpoint to get an account.
.. :quickref: Account; Get an account
This endpoint retrieves an account, given its id.
Only admins or the user themselves can use this endpoint.
**Example response**
.. sourcecode:: json
{
'id': 1,
'name': 'Test Account'
'account_roles': [1, 3],
}
:reqheader Authorization: The authentication token
:reqheader Content-Type: application/json
:resheader Content-Type: application/json
:status 200: PROCESSED
:status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
:status 401: UNAUTHORIZED
:status 403: INVALID_SENDER
:status 422: UNPROCESSABLE_ENTITY
"""
return account_schema.dump(account), 200
11 changes: 11 additions & 0 deletions flexmeasures/api/v3_0/health.py
Expand Up @@ -24,6 +24,17 @@ class HealthAPI(FlaskView):
def is_ready(self):
"""
Get readiness status
.. :quickref: Health; Get readiness status
**Example response:**
.. sourcecode:: json
{
'database_sql': True
}
"""
status = {"database_sql": _check_sql_database()} # TODO: check redis
if all(status.values()):
Expand Down
77 changes: 77 additions & 0 deletions flexmeasures/api/v3_0/tests/test_accounts_api.py
@@ -0,0 +1,77 @@
from __future__ import annotations

from flask import url_for
import pytest

from flexmeasures.data.services.users import find_user_by_email
from flexmeasures.api.tests.utils import get_auth_token


def test_get_accounts_missing_auth(client):
"""
Attempt to get accounts with missing auth.
"""
# the case without auth: authentication will fail
get_accounts_response = client.get(
url_for("AccountAPI:index"), headers=make_headers_for(None, client)
)
print("Server responded with:\n%s" % get_accounts_response.data)
assert get_accounts_response.status_code == 401


@pytest.mark.parametrize("as_admin", [True, False])
def test_get_accounts(client, setup_api_test_data, as_admin):
"""
Get accounts
"""
if as_admin:
headers = make_headers_for("test_admin_user@seita.nl", client)
else:
headers = make_headers_for("test_prosumer_user@seita.nl", client)
get_accounts_response = client.get(
url_for("AccountAPI:index"),
headers=headers,
)
print("Server responded with:\n%s" % get_accounts_response.data)
if as_admin:
assert len(get_accounts_response.json) == 5

else:
assert len(get_accounts_response.json) == 1
get_accounts_response.json[0]["name"] == "Test Prosumer Account"


@pytest.mark.parametrize(
"requesting_user_email,status_code",
[
(None, 401), # no auth is not allowed
("test_prosumer_user_2@seita.nl", 200), # gets their own account, okay
("test_dummy_user_3@seita.nl", 403), # gets from other account
("test_admin_user@seita.nl", 200), # admin can do this from another account
],
)
def test_get_one_account(
client, setup_api_test_data, requesting_user_email, status_code
):
"""Get one account"""
test_user2_account_id = find_user_by_email(
"test_prosumer_user_2@seita.nl"
).account.id
get_account_response = client.get(
url_for("AccountAPI:get", id=test_user2_account_id),
headers=make_headers_for(requesting_user_email, client),
)
print("Server responded with:\n%s" % get_account_response.data)
assert get_account_response.status_code == status_code
if status_code == 200:
assert get_account_response.json["name"] == "Test Prosumer Account"
assert get_account_response.json["account_roles"] == [
{"id": 1, "name": "Prosumer"}
]


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
8 changes: 4 additions & 4 deletions flexmeasures/api/v3_0/users.py
Expand Up @@ -98,7 +98,7 @@ def get(self, id: int, user: UserModel):
.. :quickref: User; Get a user
This endpoint gets a user.
Only admins or the user themselves can use this endpoint.
Only admins or the members of the same account can use this endpoint.
**Example response**
Expand Down Expand Up @@ -136,11 +136,11 @@ def patch(self, id: int, user: UserModel, **user_data):
.. :quickref: User; Patch data for an existing user
This endpoint sets data for an existing user.
It has to be used by the user themselves, admins or account-admins (of the same account).
Any subset of user fields can be sent.
Only the user themselves or admins are allowed to update its data,
while a non-admin can only edit a few of their own fields.
If the user is not an (account-)admin, they can only edit a few of their own fields.
The following fields are not allowed to be updated:
The following fields are not allowed to be updated at all:
- id
- account_id
Expand Down
6 changes: 5 additions & 1 deletion flexmeasures/cli/data_show.py
Expand Up @@ -113,12 +113,16 @@ def show_account(account):
user.username,
user.email,
naturaltime(user.last_login_at),
naturaltime(user.last_seen_at),
",".join([role.name for role in user.roles]),
)
for user in users
]
click.echo(
tabulate(user_data, headers=["ID", "Name", "Email", "Last Login", "Roles"])
tabulate(
user_data,
headers=["ID", "Name", "Email", "Last Login", "Last Seen", "Roles"],
)
)

click.echo()
Expand Down
32 changes: 29 additions & 3 deletions flexmeasures/data/schemas/account.py
@@ -1,17 +1,43 @@
from flask.cli import with_appcontext
from flexmeasures.data import ma
from marshmallow import fields

from flexmeasures.data.models.user import Account
from flexmeasures.data.models.user import (
Account as AccountModel,
AccountRole as AccountRoleModel,
)
from flexmeasures.data.schemas.utils import FMValidationError, MarshmallowClickMixin


class AccountRoleSchema(ma.SQLAlchemySchema):
"""AccountRole schema, with validations."""

class Meta:
model = AccountRoleModel

id = ma.auto_field(dump_only=True)
name = ma.auto_field()
accounts = fields.Nested("AccountSchema", exclude=("account_roles",), many=True)


class AccountSchema(ma.SQLAlchemySchema):
"""Account schema, with validations."""

class Meta:
model = AccountModel

id = ma.auto_field(dump_only=True)
name = ma.auto_field(required=True)
account_roles = fields.Nested("AccountRoleSchema", exclude=("accounts",), many=True)


class AccountIdField(fields.Int, MarshmallowClickMixin):
"""Field that deserializes to an Account and serializes back to an integer."""

@with_appcontext
def _deserialize(self, value, attr, obj, **kwargs) -> Account:
def _deserialize(self, value, attr, obj, **kwargs) -> AccountModel:
"""Turn an account id into an Account."""
account = Account.query.get(value)
account = AccountModel.query.get(value)
if account is None:
raise FMValidationError(f"No account found with id {value}.")
# lazy loading now (account somehow is not in the session after this)
Expand Down
21 changes: 21 additions & 0 deletions flexmeasures/data/services/accounts.py
@@ -0,0 +1,21 @@
from typing import List, Optional

from flexmeasures.data.models.user import Account, AccountRole


def get_accounts(
role_name: Optional[str] = None,
) -> List[Account]:
"""Return a list of Account objects.
The role_name parameter allows to filter by role.
"""
account_query = Account.query

if role_name is not None:
role = AccountRole.query.filter(AccountRole.name == role_name).one_or_none()
if role:
account_query = account_query.filter(Account.account_roles.contains(role))
else:
return []

return account_query.all()
2 changes: 2 additions & 0 deletions flexmeasures/ui/__init__.py
Expand Up @@ -37,11 +37,13 @@ def register_at(app: Flask):

from flexmeasures.ui.crud.assets import AssetCrudUI
from flexmeasures.ui.crud.users import UserCrudUI
from flexmeasures.ui.crud.accounts import AccountCrudUI
from flexmeasures.ui.views.sensors import SensorUI

AssetCrudUI.register(app)
UserCrudUI.register(app)
SensorUI.register(app)
AccountCrudUI.register(app)

import flexmeasures.ui.views # noqa: F401 this is necessary to load the views

Expand Down

0 comments on commit 67d2236

Please sign in to comment.