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

[WIP] Speed up assets and accounts pages #988

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0a1b890
Testing doing 1 single request instead of one request per account. Th…
victorgarcia98 Feb 26, 2024
70c5ff8
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Feb 26, 2024
7f43acf
check permissions
victorgarcia98 Feb 28, 2024
ecd4f91
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Feb 29, 2024
2acfb7f
Merge remote-tracking branch 'origin/feature/crud/get-from-index-endp…
victorgarcia98 Feb 29, 2024
31a5803
Merge remote-tracking branch 'origin/main' into feature/crud/get-from…
Flix6x Mar 11, 2024
3d3bece
Merge remote-tracking branch 'origin/main' into feature/crud/get-from…
Flix6x Mar 12, 2024
4ab2eea
Merge remote-tracking branch 'origin/main' into feature/crud/get-from…
Flix6x Mar 18, 2024
fa54b6c
fix: harmless, but redundant plus
Flix6x Mar 18, 2024
6c84bd3
fix: no dumb children
Flix6x Mar 18, 2024
88086a6
feature: create Asset objects faster by selecting from db, while stil…
Flix6x Mar 18, 2024
021bd79
style: black
Flix6x Mar 18, 2024
67a0d60
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Mar 27, 2024
38f0f11
Merge remote-tracking branch 'origin' into feature/crud/get-from-inde…
victorgarcia98 Mar 27, 2024
096921a
add public assets to the call and fix tests
victorgarcia98 Mar 27, 2024
02ac27e
add future
victorgarcia98 Mar 27, 2024
fc00b72
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Mar 27, 2024
f1f5ac0
add `all_accessible` to get all the accessible assets by a certain ac…
victorgarcia98 Apr 2, 2024
7fcb4a5
Merge remote-tracking branch 'origin/feature/crud/get-from-index-endp…
victorgarcia98 Apr 2, 2024
eec4ad4
Improve docstring.
victorgarcia98 Apr 2, 2024
a99e5cb
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Apr 2, 2024
e98dc72
raise if the account is provided and all_accessible=True
victorgarcia98 Apr 3, 2024
5b9a2b3
Merge remote-tracking branch 'origin/feature/crud/get-from-index-endp…
victorgarcia98 Apr 3, 2024
012983d
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 Apr 5, 2024
edeb490
Merge branch 'main' into feature/crud/get-from-index-endpoint
victorgarcia98 May 17, 2024
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
30 changes: 24 additions & 6 deletions flexmeasures/api/v3_0/assets.py
@@ -1,3 +1,5 @@
from __future__ import annotations

import json

from flask import current_app
Expand All @@ -18,6 +20,8 @@
from flexmeasures.api.common.schemas.users import AccountIdField
from flexmeasures.utils.coding_utils import flatten_unique
from flexmeasures.ui.utils.view_utils import set_session_variables
from flexmeasures.auth.policy import check_access
from werkzeug.exceptions import Forbidden, Unauthorized


asset_schema = AssetSchema()
Expand All @@ -38,15 +42,12 @@ class AssetAPI(FlaskView):
@route("", methods=["GET"])
@use_kwargs(
{
"account": AccountIdField(
data_key="account_id", load_default=AccountIdField.load_current
),
"account": AccountIdField(data_key="account_id", load_default=None),
},
location="query",
)
@permission_required_for_context("read", ctx_arg_name="account")
@as_json
def index(self, account: Account):
def index(self, account: Account | None):
"""List all assets owned by a certain account.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Owned or accessible? I think by now this index endpoint morphed into the latter. In which case, should we include the public assets? Unless an account is passed, of course.

The docstring would need a rewrite, too.

Technically, it's also a breaking change in the API. We could maintain backwards compatibility if we add a new optional field that signals the caller wants to get all accessible accounts instead of the assets their own account owns. Or a new endpoint, but I don't favour that option, because it's still an asset index endpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, good point. I've included the parameter all_accessible (default=False).
This parameter signals if we want to get all the assets that the requesting user has read permissions or, by default, the ones that it owns.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What might nicely round this up would be to revise check_access to accept an as_account argument that defaults to the current user, in combination with putting back the permissions decorator.

So then we'd have the following four cases:

  1. No account is passed, and all_accessible=false: caller wants to list their own assets
  2. No account is passed, and all_accessible=true: caller wants to list all assets that they can access
  3. An account is passed, and all_accessible=false: caller wants to list some other account's assets
  4. an account is passed, and all_accessible=true: caller wants to list all assets that some other account can access. Caller requires read access on the passed account (checked using the decorator) and the passed account requires read access on the assets (to be checked using the revised check_access I suggested above).

One open point would be whether we should implicitly assume that, if the caller has access to account, it also has access to all accounts that account has access to. That is, we assume consultant permissions propagate.

Alternatively, we could call check_access on the assets for both the caller and the account (in case they are not the same). That is, we do not assume consultant permissions propagate.

Propagation has my preference. Whichever the case, we also might want to mention the choice of policy explicitly in the docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unlikely case but possible is the user to have consultancy role but no read role. Thus, I think we can't really bring permission_required_for_context back. Perhaps, if len(accounts) == 0, we should raise Forbidden or Unauthorized.

Regarding the changes to the check_access, to my (superficial) understanding, they are already covered by the authorization policy:

  1. No account is passed, and all_accessible=false: caller wants to list their own assets

Given that account is not passed, the marshmallow field will default to AccountIdField.load_current and check_access will make sure that the current user has read permissions for the account.

  1. No account is passed, and all_accessible=true: caller wants to list all assets that they can access

In this scenario, the user could or couldn't have read access to his/her own account. Yet, it could have a consultant role and have access over the accounts that have the consultant_account_id set to the user's account.

  1. An account is passed, and all_accessible=false: caller wants to list some other account's assets

check_access make sure that the requesting user has read permissions to its own account.

  1. An account is passed, and all_accessible=true: caller wants to list all assets that some other account can access. Caller requires read access on the passed account (checked using the decorator) and the passed account requires read access on the assets (to be checked using the revised check_access I suggested above).

Here probably we should raise if the current user has no read permissions for its own account even if it has consultant role.

One open point would be whether we should implicitly assume that, if the caller has access to account, it also has access to all accounts that account has access to. That is, we assume consultant permissions propagate.

This is an interesting point, indeed. I think we should open a new issue for this and handle that on the Account __acl__ method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this touches auth policies I want to make sure we get @nhoening 's opinion, too.

Copy link
Contributor

@nhoening nhoening Apr 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting solution!

  • With the new structure, I guess we could deprecate the /public endpoint (as we can return public endpoints here), but if one * only* wants public assets, that would require loading a potentially long list and parsing the result. Or we add another parameter only_public.
  • Consultancy is not propagating across multiple levels of accounts, and this is not the time or place to go there. We don't need it. Let's leave authorization (and check_access where it is right now)
  • "An unlikely case but possible is the user to have consultancy role but no read role." I don't understand this. If you have that role, and your account is consultant of a client account, you can read client account data.
  • I would also not do Case 4 ("An account is passed, and all_accessible=true: caller wants to list all assets that some other account can access."). Or is there a real use case? Why should we go to this trouble? We could say that both are not possible together and raise 422.
  • Docstring can still be better: "List all assets that the user can access, on a given account (default: own) or all that the user has permission to read (excluding public)".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"An unlikely case but possible is the user to have consultancy role but no read role." I don't understand this. If you have that role, and your account is consultant of a client account, you can read client account data.

This is the case that a user has consultant roles but no doesn't have the read. This means that it can read their client's account but not his/her.

I would also not do Case 4 ("An account is passed, and all_accessible=true: caller wants to list all assets that some other account can access."). Or is there a real use case? Why should we go to this trouble? We could say that both are not possible together and raise 422.

I agree, currently we don't need to check what a user belonging to a different account can list. Also, If we were to implement this, it gets more complex because the user roles + account roles + consultancy relationship determine if a user can read an asset.


.. :quickref: Asset; Download asset list
Expand Down Expand Up @@ -80,7 +81,24 @@ def index(self, account: Account):
:status 403: INVALID_SENDER
:status 422: UNPROCESSABLE_ENTITY
"""
return assets_schema.dump(account.generic_assets), 200

if account is not None:
check_access(account, "read")
accounts = [account]
else:
accounts = []
for account in db.session.scalars(select(Account)).all():
try:
check_access(account, "read")
accounts.append(account)
except (Forbidden, Unauthorized):
pass

assets = []
for account in accounts:
assets.extend(account.generic_assets)

return assets_schema.dump(assets), 200

@route("/public", methods=["GET"])
@as_json
Expand Down
34 changes: 27 additions & 7 deletions flexmeasures/api/v3_0/users.py
@@ -1,11 +1,13 @@
from flask_classful import FlaskView, route
from marshmallow import fields
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select
from webargs.flaskparser import use_kwargs
from flask_security import current_user, auth_required
from flask_security.recoverable import send_reset_password_instructions
from flask_json import as_json
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import Forbidden, Unauthorized
from flexmeasures.auth.policy import check_access

from flexmeasures.data.models.user import User as UserModel, Account
from flexmeasures.api.common.schemas.users import AccountIdField, UserIdField
Expand Down Expand Up @@ -38,22 +40,22 @@ class UserAPI(FlaskView):
@route("", methods=["GET"])
@use_kwargs(
{
"account": AccountIdField(
data_key="account_id", load_default=AccountIdField.load_current
),
"account": AccountIdField(data_key="account_id", load_default=None),
"include_inactive": fields.Bool(load_default=False),
},
location="query",
)
@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.
"""API endpoint to list all users.


.. :quickref: User; Download user list

This endpoint returns all accessible users.
By default, only active users are returned.
The `account_id` query parameter can be used to filter the users of
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
a given account.
The `include_inactive` query parameter can be used to also fetch
inactive users.
Accessible users are users in the same account as the current user.
Expand Down Expand Up @@ -86,7 +88,25 @@ def index(self, account: Account, include_inactive: bool = False):
:status 403: INVALID_SENDER
:status 422: UNPROCESSABLE_ENTITY
"""
users = get_users(account_name=account.name, only_active=not include_inactive)

if account is not None:
check_access(account, "read")
accounts = [account]
else:
accounts = []
for account in db.session.scalars(select(Account)).all():
try:
check_access(account, "read")
accounts.append(account)
except (Forbidden, Unauthorized):
pass

users = []
for account in accounts:
users += get_users(
account_name=account.name,
only_active=not include_inactive,
)
return users_schema.dump(users), 200

@route("/<id>")
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/data/schemas/generic_assets.py
Expand Up @@ -44,7 +44,7 @@ class GenericAssetSchema(ma.SQLAlchemySchema):
generic_asset_type_id = fields.Integer(required=True)
attributes = JSON(required=False)
parent_asset_id = fields.Int(required=False, allow_none=True)
child_assets = ma.Nested("GenericAssetSchema", many=True, dumb_only=True)
child_assets = ma.Nested("GenericAssetSchema", many=True, dump_only=True)

class Meta:
model = GenericAsset
Expand Down
25 changes: 17 additions & 8 deletions flexmeasures/ui/crud/assets.py
Expand Up @@ -208,6 +208,22 @@ def get_assets_by_account(account_id: int | str | None) -> list[GenericAsset]:
]


def get_all_assets() -> list[GenericAsset]:
get_assets_response = InternalApi().get(url_for("AssetAPI:index")).json()
get_assets_response_public = InternalApi().get(url_for("AssetAPI:public")).json()

assets = []
if isinstance(get_assets_response, list):
assets.extend(get_assets_response)

if isinstance(get_assets_response_public, list):
assets.extend(get_assets_response_public)

asset_ids = [GenericAsset.id.in_(ad["id"] for ad in assets)]
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved

return db.session.scalars(select(GenericAsset).where(*asset_ids)).all()


class AssetCrudUI(FlaskView):
"""
These views help us offer a Jinja2-based UI.
Expand All @@ -225,14 +241,7 @@ def index(self, msg=""):

List the user's assets. For admins, list across all accounts.
"""
assets = []

if user_has_admin_access(current_user, "read"):
for account in db.session.scalars(select(Account)).all():
assets += get_assets_by_account(account.id)
assets += get_assets_by_account(account_id=None)
else:
assets = get_assets_by_account(current_user.account_id)
assets = get_all_assets()

return render_flexmeasures_template(
"crud/assets.html",
Expand Down
26 changes: 16 additions & 10 deletions flexmeasures/ui/crud/users.py
Expand Up @@ -4,7 +4,6 @@

from flask import request, url_for
from flask_classful import FlaskView
from flask_login import current_user
from flask_security import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, FloatField, DateTimeField, BooleanField
Expand Down Expand Up @@ -92,6 +91,20 @@ def get_users_by_account(
return users


def get_all_users(include_inactive: bool = False) -> list[User]:
get_users_response = InternalApi().get(
url_for(
"UserAPI:index",
include_inactive=include_inactive,
)
)
users = [
process_internal_api_response(user, make_obj=True)
for user in get_users_response.json()
]
return users


class UserCrudUI(FlaskView):
route_base = "/users"
trailing_slash = False
Expand All @@ -100,15 +113,8 @@ class UserCrudUI(FlaskView):
def index(self):
"""/users"""
include_inactive = request.args.get("include_inactive", "0") != "0"
users = []
if current_user.has_role(ADMIN_ROLE) or current_user.has_role(
ADMIN_READER_ROLE
):
accounts = db.session.scalars(select(Account)).all()
else:
accounts = [current_user.account]
for account in accounts:
users += get_users_by_account(account.id, include_inactive)
users = get_all_users(include_inactive)

return render_flexmeasures_template(
"crud/users.html", users=users, include_inactive=include_inactive
)
Expand Down
19 changes: 19 additions & 0 deletions flexmeasures/ui/tests/conftest.py
Expand Up @@ -2,6 +2,7 @@

from flexmeasures.data.services.users import create_user
from flexmeasures.ui.tests.utils import login, logout
from flexmeasures import Asset


@pytest.fixture(scope="function")
Expand Down Expand Up @@ -41,3 +42,21 @@ def setup_ui_test_data(
account_name=setup_accounts["Prosumer"].name,
user_roles=dict(name="admin", description="A site admin."),
)


@pytest.fixture
def assets_prosumer(db, setup_accounts, setup_generic_asset_types):
assets = []
for name in ["TestAsset", "TestAsset2"]:
asset = Asset(
name=name,
generic_asset_type=setup_generic_asset_types["battery"],
owner=setup_accounts["Prosumer"],
latitude=70.4,
longitude=30.9,
)
assets.append(asset)

db.session.add_all(assets)

return assets
15 changes: 6 additions & 9 deletions flexmeasures/ui/tests/test_asset_crud.py
Expand Up @@ -16,28 +16,25 @@


def test_assets_page_empty(db, client, requests_mock, as_prosumer_user1):
requests_mock.get(f"{api_path_assets}?account_id=1", status_code=200, json={})
requests_mock.get(f"{api_path_assets}/public", status_code=200, json={})
requests_mock.get(f"{api_path_assets}", status_code=200, json=[])
requests_mock.get(f"{api_path_assets}/public", status_code=200, json=[])
asset_index = client.get(url_for("AssetCrudUI:index"), follow_redirects=True)
assert asset_index.status_code == 200


def test_get_assets_by_account(db, client, requests_mock, as_prosumer_user1):
mock_assets = mock_asset_response(multiple=True)
requests_mock.get(
f"{api_path_assets}?account_id=1", status_code=200, json=mock_assets
)
requests_mock.get(f"{api_path_assets}", status_code=200, json=mock_assets)
assert get_assets_by_account(1)[1].name == "TestAsset2"


@pytest.mark.parametrize("use_owned_by", [False, True])
def test_assets_page_nonempty(
db, client, requests_mock, as_prosumer_user1, use_owned_by
db, client, requests_mock, as_prosumer_user1, use_owned_by, assets_prosumer
):
mock_assets = mock_asset_response(multiple=True)
requests_mock.get(
f"{api_path_assets}?account_id=1", status_code=200, json=mock_assets
)
requests_mock.get(f"{api_path_assets}", status_code=200, json=mock_assets)
requests_mock.get(f"{api_path_assets}/public", status_code=200, json=[])
if use_owned_by:
asset_index = client.get(
url_for("AssetCrudUI:owned_by", account_id=mock_assets[0]["account_id"])
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/ui/tests/test_user_crud.py
Expand Up @@ -15,7 +15,7 @@

def test_get_users_by_account(client, requests_mock, as_prosumer_user1):
requests_mock.get(
f"http://localhost//api/v3_0/users?account_id={current_user.account.id}",
"http://localhost//api/v3_0/users",
status_code=200,
json=mock_user_response(multiple=True),
)
Expand Down
9 changes: 7 additions & 2 deletions flexmeasures/ui/tests/test_views.py
Expand Up @@ -21,9 +21,14 @@ def test_dashboard_responds_only_for_logged_in_users(client, as_prosumer_user1):

def test_assets_responds(client, requests_mock, as_prosumer_user1):
requests_mock.get(
"http://localhost//api/v3_0/assets?account_id=1",
"http://localhost//api/v3_0/assets",
status_code=200,
json={},
json=[],
)
requests_mock.get(
"http://localhost//api/v3_0/assets/public",
status_code=200,
json=[],
)
assets_page = client.get(url_for("AssetCrudUI:index"), follow_redirects=True)
assert assets_page.status_code == 200
Expand Down
3 changes: 2 additions & 1 deletion flexmeasures/ui/tests/utils.py
Expand Up @@ -20,7 +20,7 @@ def logout(client):


def mock_asset_response(
asset_id: int = 1,
asset_id: int = 2,
account_id: int = 1,
as_list: bool = True,
multiple: bool = False,
Expand All @@ -38,6 +38,7 @@ def mock_asset_response(
if multiple:
asset2 = copy.deepcopy(asset)
asset2["name"] = "TestAsset2"
asset2["id"] += 1
asset_list.append(asset2)
return asset_list
return asset
Expand Down