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

432 account overview list details page #605

Merged
merged 37 commits into from Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cc91a96
Add user.last_seen to CLI command show account
nhoening Mar 9, 2023
6f878dd
Document health API
nhoening Mar 9, 2023
4318518
Start /accounts API: GET endpoints
nhoening Mar 9, 2023
aa80880
remove two debugging statements
nhoening Mar 9, 2023
4364b52
use utility function for headers, fix typo
nhoening Mar 13, 2023
f0988f2
support new-style Optional shorthand in Python > 3.10
nhoening Mar 13, 2023
56d4dea
Merge branch 'main' into 432-account-overview-list-details-page
nhoening Mar 13, 2023
f0e5a32
update the number of accounts, as PR 602 added two more
nhoening Mar 13, 2023
a765bc5
feat(CRUD): add R of accounts CRUD
Mar 15, 2023
cbf73ce
three textual corrections
nhoening Mar 15, 2023
869890b
get_accounts util function returns empty list if unknown role is pass…
nhoening Mar 15, 2023
a33f42f
feat(CRUD): updated accounts tests and added links to user pages
Mar 20, 2023
19d49f7
feat(CRUD): add accounts to navbar and get single account through api
Mar 21, 2023
2310160
feat(CRUD): review comments implemented
Mar 21, 2023
24d40dd
fix docstring in API /index
nhoening Mar 23, 2023
20f61f9
chore(changelog): API changelog added accounts endpoints
Mar 23, 2023
b37fc5b
chore(changelog): changelog added accounts endpoints and pages
Mar 23, 2023
0ab0aa6
chore(changelog): changelog added accounts endpoints and pages
Mar 23, 2023
b6b5820
Merge branch 'main' into 432-account-overview-list-details-page
GustaafL Mar 23, 2023
455c92a
chore(changelog): changelog added accounts endpoints and pages
Mar 23, 2023
5307d5a
chore(changelog): PR number corrected
GustaafL Mar 24, 2023
c8e33f2
Merge branch 'main' into 432-account-overview-list-details-page
GustaafL Mar 24, 2023
8509d10
feat(account): accountpage updated with asset and user info
Mar 28, 2023
d098f03
feat(accounts): Add user and asset counts to accounts page
Apr 4, 2023
58409f6
Add account role schema (#625)
Flix6x Apr 5, 2023
cb4d78d
test
Apr 6, 2023
bcd584f
feat(accounts): account role names added to the account pages
Apr 6, 2023
b768c1b
Merge branch 'main' into 432-account-overview-list-details-page
GustaafL Apr 6, 2023
8d152b5
refactor(users): review changes, updated changelog and typing
Apr 7, 2023
1b342fe
refactor(crud): review changes to html templates
Apr 13, 2023
10d4d9a
Remove redundant lines
Flix6x Apr 17, 2023
7413643
Fix type annotations
Flix6x Apr 17, 2023
e1f49e8
Fix button for including inactive users
Flix6x Apr 17, 2023
becb83d
Move button to the side
Flix6x Apr 17, 2023
711396a
Merge remote-tracking branch 'origin/main' into 432-account-overview-…
Flix6x Apr 17, 2023
557fbaf
Fix data sort on copy of users table
Flix6x Apr 17, 2023
09f2a52
Add missing decorators to fix redirects to the login page
Flix6x Apr 17, 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
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 its account roles: `/accounts` (GET)
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
- 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 @@ -13,6 +13,7 @@ New features
* Keyboard control over replay [see `PR #562 <https://www.github.com/FlexMeasures/flexmeasures/pull/562>`_]
* 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>`_]

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):
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""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))
nhoening marked this conversation as resolved.
Show resolved Hide resolved
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