From 0a4f300a2b93b49e83bc08d1c3bd7df3b2c10044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Thu, 21 Jul 2022 11:43:46 +0200 Subject: [PATCH] Issue 423 improve dashboard and asset listing for admins (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improve auth behaviour of query utility function, and also give it a better name Signed-off-by: Nicolas Höning * add account attribute to generic_asset Signed-off-by: Nicolas Höning * For admins, show next to assets on dashboard what account they are on Signed-off-by: Nicolas Höning * add switch button to group assets on dashboard by accounts (for admins) Signed-off-by: Nicolas Höning * using asset IDs in marker variable names is safer, as asset names are not unique (anymore) Signed-off-by: Nicolas Höning * Assets Ui page shows all account assets for admin(-reader)s Signed-off-by: Nicolas Höning * add changelog entry Signed-off-by: Nicolas Höning * correct treatmnt of session in CRUD asset views, remove unnecessary attribute added earlier Signed-off-by: Nicolas Höning * fix one more test Signed-off-by: Nicolas Höning * implement review comments Signed-off-by: Nicolas Höning * Let the asets page display public assets. Add GET /assets/public endpoint, so public assets can be read as well (/assets needs an account, so this is the only clean solution). Signed-off-by: Nicolas Höning --- documentation/changelog.rst | 4 +- flexmeasures/api/v3_0/assets.py | 22 +++++ .../api/v3_0/tests/test_assets_api.py | 11 +++ flexmeasures/data/queries/generic_assets.py | 8 +- flexmeasures/data/queries/sensors.py | 6 +- flexmeasures/data/queries/utils.py | 7 +- flexmeasures/data/services/asset_grouping.py | 45 +++++++--- flexmeasures/ui/__init__.py | 2 + flexmeasures/ui/crud/assets.py | 49 ++++++++--- flexmeasures/ui/static/css/flexmeasures.css | 55 ++++++++++++ flexmeasures/ui/templates/crud/assets.html | 12 ++- .../ui/templates/views/new_dashboard.html | 87 ++++++++++--------- flexmeasures/ui/tests/test_asset_crud.py | 2 + flexmeasures/ui/tests/test_views.py | 2 +- flexmeasures/ui/utils/view_utils.py | 11 +-- flexmeasures/ui/views/new_dashboard.py | 28 ++++-- 16 files changed, 256 insertions(+), 95 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index f19ce392f..d59bb968e 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -9,8 +9,9 @@ New features ------------- * The asset page now shows the most relevant sensor data for the asset [see `PR #449 `_] * Individual sensor charts show available annotations [see `PR #428 `_] -* Add CLI command ``flexmeasures jobs show-queues`` [see `PR #455 `_] +* Admins can group assets by account on dashboard & assets page [see `PR #461 `_] * Collapsible side-panel (hover/swipe) used for date selection on sensor charts, and various styling improvements [see `PR #447 `_ and `PR #448 `_] +* Add CLI command ``flexmeasures jobs show-queues`` [see `PR #455 `_] * Switched from 12-hour AM/PM to 24-hour clock notation for time series chart axis labels [see `PR #446 `_] * Get data in a given resolution [see `PR #458 `_] @@ -24,6 +25,7 @@ Infrastructure / Support * Docker compose stack now with Redis worker queue [see `PR #455 `_] * Allow access tokens to be passed as env vars as well [see `PR #443 `_] * Queue workers can get initialised without a custom name and name collisions are handled [see `PR #455 `_] +* New API endpoint to get public assets [see `PR #461 `_] v0.10.1 | June XX, 2022 diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 6bed518b8..641201774 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -2,6 +2,7 @@ from flask import current_app from flask_classful import FlaskView, route +from flask_security import login_required from flask_json import as_json from marshmallow import fields from webargs.flaskparser import use_kwargs, use_args @@ -77,6 +78,27 @@ def index(self, account: Account): """ return assets_schema.dump(account.generic_assets), 200 + @route("/public", methods=["GET"]) + @login_required + @as_json + def public(self): + """Return all public assets. + + .. :quickref: Asset; Return all public assets. + + This endpoint returns all public assets. + + :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 422: UNPROCESSABLE_ENTITY + """ + assets = GenericAsset.query.filter(GenericAsset.account_id.is_(None)).all() + return assets_schema.dump(assets), 200 + @route("", methods=["POST"]) @permission_required_for_context( "create-children", arg_loader=AccountIdField.load_current diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 53cde883e..56ee1da38 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -100,6 +100,17 @@ def test_get_assets( assert turbine["account_id"] == setup_accounts["Supplier"].id +def test_get_public_assets(client, setup_api_test_data, setup_accounts): + auth_token = get_auth_token(client, "test_admin_user@seita.nl", "testtest") + get_assets_response = client.get( + url_for("AssetAPI:public"), + headers={"content-type": "application/json", "Authorization": auth_token}, + ) + print("Server responded with:\n%s" % get_assets_response.json) + assert len(get_assets_response.json) == 1 + assert get_assets_response.json[0]["name"] == "troposphere" + + def test_alter_an_asset(client, setup_api_test_data, setup_accounts): # without being an account-admin, no asset can be created ... with UserContext("test_prosumer_user@seita.nl") as prosumer1: diff --git a/flexmeasures/data/queries/generic_assets.py b/flexmeasures/data/queries/generic_assets.py index 7c6651258..f5a1e987a 100644 --- a/flexmeasures/data/queries/generic_assets.py +++ b/flexmeasures/data/queries/generic_assets.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Query from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType -from flexmeasures.data.queries.utils import potentially_limit_query_to_account_assets +from flexmeasures.data.queries.utils import potentially_limit_assets_query_to_account def query_assets_by_type( @@ -28,7 +28,7 @@ def query_assets_by_type( query = query.filter(GenericAssetType.name == type_names) else: query = query.filter(GenericAssetType.name.in_(type_names)) - query = potentially_limit_query_to_account_assets(query, account_id) + query = potentially_limit_assets_query_to_account(query, account_id) return query @@ -52,7 +52,7 @@ def get_location_queries(account_id: Optional[int] = None) -> Dict[str, Query]: :param account_id: Pass in an account ID if you want to query an account other than your own. This only works for admins. Public assets are always queried. """ asset_queries = {} - all_assets = potentially_limit_query_to_account_assets( + all_assets = potentially_limit_assets_query_to_account( GenericAsset.query, account_id ).all() loc_groups = group_assets_by_location(all_assets) @@ -71,7 +71,7 @@ def get_location_queries(account_id: Optional[int] = None) -> Dict[str, Query]: location_query = GenericAsset.query.filter( GenericAsset.name.in_([asset.name for asset in loc_group]) ) - asset_queries[location_name] = potentially_limit_query_to_account_assets( + asset_queries[location_name] = potentially_limit_assets_query_to_account( location_query, account_id ) return asset_queries diff --git a/flexmeasures/data/queries/sensors.py b/flexmeasures/data/queries/sensors.py index 4a751ff45..30650b271 100644 --- a/flexmeasures/data/queries/sensors.py +++ b/flexmeasures/data/queries/sensors.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Query from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType -from flexmeasures.data.queries.utils import potentially_limit_query_to_account_assets +from flexmeasures.data.queries.utils import potentially_limit_assets_query_to_account def query_sensor_by_name_and_generic_asset_type_name( @@ -30,7 +30,7 @@ def query_sensor_by_name_and_generic_asset_type_name( .filter(GenericAsset.generic_asset_type_id == GenericAssetType.id) .filter(Sensor.generic_asset_id == GenericAsset.id) ) - query = potentially_limit_query_to_account_assets(query, account_id) + query = potentially_limit_assets_query_to_account(query, account_id) return query @@ -59,7 +59,7 @@ def query_sensors_by_proximity( closest_sensor_query = closest_sensor_query.order_by( GenericAsset.great_circle_distance(lat=latitude, lng=longitude).asc() ) - closest_sensor_query = potentially_limit_query_to_account_assets( + closest_sensor_query = potentially_limit_assets_query_to_account( closest_sensor_query, account_id ) return closest_sensor_query diff --git a/flexmeasures/data/queries/utils.py b/flexmeasures/data/queries/utils.py index 5a7e7b9b6..6c497d423 100644 --- a/flexmeasures/data/queries/utils.py +++ b/flexmeasures/data/queries/utils.py @@ -42,8 +42,9 @@ def create_beliefs_query( return query -def potentially_limit_query_to_account_assets( - query: Query, account_id: Optional[int] +def potentially_limit_assets_query_to_account( + query: Query, + account_id: Optional[int] = None, ) -> Query: """Filter out all assets that are not in the current user's account. For admins and CLI users, no assets are filtered out, unless an account_id is set. @@ -55,7 +56,7 @@ def potentially_limit_query_to_account_assets( user_is_admin = ( running_as_cli() or current_user.has_role(ADMIN_ROLE) - or current_user.has_role(ADMIN_READER_ROLE) + or (query.statement.is_select and current_user.has_role(ADMIN_READER_ROLE)) ) if account_id is None and user_is_admin: return query # allow admins to query assets across all accounts diff --git a/flexmeasures/data/services/asset_grouping.py b/flexmeasures/data/services/asset_grouping.py index b037f662c..cb292d776 100644 --- a/flexmeasures/data/services/asset_grouping.py +++ b/flexmeasures/data/services/asset_grouping.py @@ -5,9 +5,11 @@ from __future__ import annotations from typing import List, Dict, Optional +from flask_login import current_user import inflect from sqlalchemy.orm import Query +from flexmeasures.auth.policy import user_has_admin_access from flexmeasures.utils.flexmeasures_inflection import parameterize, pluralize from flexmeasures.data.models.generic_assets import ( @@ -15,6 +17,7 @@ GenericAsset, assets_share_location, ) +from flexmeasures.data.models.user import Account from flexmeasures.data.queries.generic_assets import ( query_assets_by_type, get_location_queries, @@ -24,35 +27,49 @@ def get_asset_group_queries( - custom_additional_groups: Optional[Dict[str, List[str]]] = None, + group_by_type: bool = True, + group_by_account: bool = False, group_by_location: bool = False, + custom_aggregate_type_groups: Optional[Dict[str, List[str]]] = None, ) -> Dict[str, Query]: """ - An asset group is defined by Asset queries. Each query has a name, and we prefer pluralised names. - They still need an executive call, like all(), count() or first(). + An asset group is defined by Asset queries, which this function can generate. + + Each query has a name (for the asset group it represents). + These queries still need an executive call, like all(), count() or first(). This function limits the assets to be queried to the current user's account, if the user is not an admin. - Note: Make sure the current user has the "read" permission on his account (on GenericAsset.__class__?? See https://github.com/FlexMeasures/flexmeasures/issues/200). + Note: Make sure the current user has the "read" permission on their account (on GenericAsset.__class__?? See https://github.com/FlexMeasures/flexmeasures/issues/200) or is an admin. - :param custom_additional_groups: dict of asset type groupings (mapping group names to names of asset types). See also the setting FLEXMEASURES_ASSET_TYPE_GROUPS. + :param group_by_type: If True, groups will be made for assets with the same type. We prefer pluralised group names here. Defaults to True. + :param group_by_account: If True, groups will be made for assets within the same account. This makes sense for admins, as they can query across accounts. :param group_by_location: If True, groups will be made for assets at the same location. Naming of the location currently supports charge points (for EVSEs). + :param custom_aggregate_type_groups: dict of asset type groupings (mapping group names to names of asset types). See also the setting FLEXMEASURES_ASSET_TYPE_GROUPS. """ asset_queries = {} # 1. Custom asset groups by combinations of asset types - if custom_additional_groups: - for asset_type_group_name, asset_types in custom_additional_groups.items(): + if custom_aggregate_type_groups: + for asset_type_group_name, asset_types in custom_aggregate_type_groups.items(): asset_queries[asset_type_group_name] = query_assets_by_type(asset_types) - # 2. We also include a group per asset type - using the pluralised asset type name - for asset_type in GenericAssetType.query.all(): - asset_queries[pluralize(asset_type.name)] = query_assets_by_type( - asset_type.name - ) - - # 3. Finally, we group assets by location + # 2. Include a group per asset type - using the pluralised asset type name + if group_by_type: + for asset_type in GenericAssetType.query.all(): + asset_queries[pluralize(asset_type.name)] = query_assets_by_type( + asset_type.name + ) + + # 3. Include a group per account (admins only) # TODO: we can later adjust this for accounts who admin certain others, not all + if group_by_account and user_has_admin_access(current_user, "read"): + for account in Account.query.all(): + asset_queries[account.name] = GenericAsset.query.filter( + GenericAsset.account_id == account.id + ) + + # 4. Finally, we can group assets by location if group_by_location: asset_queries.update(get_location_queries()) diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py index 81c43c695..60fdd6204 100644 --- a/flexmeasures/ui/__init__.py +++ b/flexmeasures/ui/__init__.py @@ -12,6 +12,7 @@ from flexmeasures.utils.flexmeasures_inflection import ( capitalize, parameterize, + pluralize, ) from flexmeasures.utils.time_utils import ( localized_datetime_str, @@ -128,6 +129,7 @@ def add_jinja_filters(app): app.jinja_env.filters["naturalized_datetime"] = naturalized_datetime_str app.jinja_env.filters["naturalized_timedelta"] = naturaldelta app.jinja_env.filters["capitalize"] = capitalize + app.jinja_env.filters["pluralize"] = pluralize app.jinja_env.filters["parameterize"] = parameterize app.jinja_env.filters["isnull"] = pd.isnull app.jinja_env.filters["hide_nan_if_desired"] = ( diff --git a/flexmeasures/ui/crud/assets.py b/flexmeasures/ui/crud/assets.py index 0e03c6fd8..a49339d97 100644 --- a/flexmeasures/ui/crud/assets.py +++ b/flexmeasures/ui/crud/assets.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, Tuple +from typing import Union, Optional, List, Tuple import copy from flask import url_for, current_app @@ -7,7 +7,7 @@ from flask_security import login_required, current_user from wtforms import StringField, DecimalField, SelectField from wtforms.validators import DataRequired -from flexmeasures.auth.policy import ADMIN_ROLE +from flexmeasures.auth.policy import user_has_admin_access from flexmeasures.data import db from flexmeasures.auth.error_handling import unauthorized_handler @@ -112,6 +112,8 @@ def process_internal_api_response( """ Turn data from the internal API into something we can use to further populate the UI. Either as an asset object or a dict for form filling. + + If we add other data by querying the database, we make sure the asset is not in the session afterwards. """ def expunge_asset(): @@ -127,12 +129,14 @@ def expunge_asset(): asset.generic_asset_type = GenericAssetType.query.get( asset.generic_asset_type_id ) + expunge_asset() + asset.owner = Account.query.get(asset_data["account_id"]) + expunge_asset() if "id" in asset_data: - expunge_asset() asset.sensors = Sensor.query.filter( Sensor.generic_asset_id == asset_data["id"] ).all() - expunge_asset() + expunge_asset() return asset return asset_data @@ -149,16 +153,33 @@ class AssetCrudUI(FlaskView): @login_required def index(self, msg=""): - """/assets""" - get_assets_response = InternalApi().get( - url_for("AssetAPI:index"), query={"account_id": current_user.account_id} - ) - assets = [ - process_internal_api_response(ad, make_obj=True) - for ad in get_assets_response.json() - ] + """GET from /assets + + List the user's assets. For admins, list across all accounts. + """ + assets = [] + + def get_asset_by_account(account_id) -> List[GenericAsset]: + if account_id is not None: + get_assets_response = InternalApi().get( + url_for("AssetAPI:index"), query={"account_id": account_id} + ) + else: + get_assets_response = InternalApi().get(url_for("AssetAPI:public")) + return [ + process_internal_api_response(ad, make_obj=True) + for ad in get_assets_response.json() + ] + + if user_has_admin_access(current_user, "read"): + for account in Account.query.all(): + assets += get_asset_by_account(account.id) + assets += get_asset_by_account(account_id=None) + else: + assets = get_asset_by_account(current_user.account_id) + return render_flexmeasures_template( - "crud/assets.html", account=current_user.account, assets=assets, message=msg + "crud/assets.html", assets=assets, message=msg ) @login_required @@ -347,7 +368,7 @@ def _set_account(asset_form: NewAssetForm) -> Tuple[Optional[Account], Optional[ account_error = None if asset_form.account_id.data == -1: - if current_user.has_role(ADMIN_ROLE): + if user_has_admin_access(current_user, "update"): return None, None # Account can be None (public asset) else: account_error = "Please pick an existing account." diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css index 4f9238068..38d022cd1 100644 --- a/flexmeasures/ui/static/css/flexmeasures.css +++ b/flexmeasures/ui/static/css/flexmeasures.css @@ -343,6 +343,61 @@ p.error { /* --- End Nav Bar --- */ +/* --- Switch button --- */ + +.switch { + position: relative; + display: inline-block; + width: 60px; + height: 34px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: var(--secondary-color); +} + +input:focus + .slider { + box-shadow: 0 0 1px var(--secondary-hover-color); +} + +input:checked + .slider:before { + -webkit-transform: translateX(26px); + -ms-transform: translateX(26px); + transform: translateX(26px); +} + +/* --- End Switch button --- */ + /* --- Footer --- */ diff --git a/flexmeasures/ui/templates/crud/assets.html b/flexmeasures/ui/templates/crud/assets.html index 3d6134b3e..0d2dcbf5d 100644 --- a/flexmeasures/ui/templates/crud/assets.html +++ b/flexmeasures/ui/templates/crud/assets.html @@ -10,14 +10,14 @@
-

All assets owned by account {{account.name}}

+

Asset overview

- + {% endfor %} + {% if not group_by_accounts %} + - {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %} - {% endfor %} - {% if not user_is_admin %} - - - - {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %} - - {% endfor %} - {% endif %} - {% if user_is_admin or FLEXMEASURES_MODE == "demo" %} - + {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %} - {% endfor %} - {% endif %}
Name Location Asset idAccount idAccount Sensors {% if user_is_admin %} @@ -34,7 +34,7 @@

All assets owned by account {{account.name}}

{{ asset.name }} + href="/assets/{{ asset.id }}" alt="View this asset">{{ asset.name }} {% if asset.latitude and asset.longitude %} @@ -46,7 +46,11 @@

All assets owned by account {{account.name}}

{{ asset.id }}
- {{ asset.account_id }} + {% if asset.owner %} + {{ asset.owner.name }} + {% else %} + PUBLIC + {% endif %} {{ asset.sensors | length }} diff --git a/flexmeasures/ui/templates/views/new_dashboard.html b/flexmeasures/ui/templates/views/new_dashboard.html index 3472451a4..40d89634e 100644 --- a/flexmeasures/ui/templates/views/new_dashboard.html +++ b/flexmeasures/ui/templates/views/new_dashboard.html @@ -20,48 +20,58 @@ {# On demo, show all non-empty groups, otherwise show all groups that are non-empty for the current user #} - + class="text-center{% if asset_group_name in aggregate_type_groups %} agg-group{% endif %}"> {{ asset_group_name | capitalize }} -
+
My assets: - {{ asset_groups[asset_group_name].count }}
{{ FLEXMEASURES_PLATFORM_NAME }} total: + {% if user_has_admin_reader_rights %} + {{ FLEXMEASURES_PLATFORM_NAME }} total: + {% else %} + My assets: + {% endif %} + + {{ asset_groups[asset_group_name].count }}
+ {% if user_has_admin_reader_rights %} +
+
+ Group by account: + +
+
+ + {% endif %}
@@ -88,52 +98,49 @@ $(".bokeh-state-plot").html(data); } - // create lists for markers, one per asset type, and icons, one per asset type - - {% for asset_group_name in asset_groups %} - {% if asset_group_name not in aggregate_groups %} - {% if asset_groups[asset_group_name].count > 0 %} - var {{ asset_groups[asset_group_name].parameterized_name }}_markers = []; + // Make an icon for each known asset type + {% for asset_type_name in known_asset_types %} - var {{ asset_groups[asset_group_name].parameterized_name }}_icon = new L.DivIcon({ + var {{ asset_type_name | parameterize | pluralize }}_icon = new L.DivIcon({ className: 'map-icon', - html: '', + html: '', iconSize: [100, 100], // size of the icon iconAnchor: [50, 50], // point of the icon which will correspond to marker's location popupAnchor: [0, -50] // point from which the popup should open relative to the iconAnchor }); - var {{ asset_groups[asset_group_name].parameterized_name }}_opportunity_icon = new L.DivIcon({ + /* + var {{ asset_type_name | parameterize | pluralize }}_opportunity_icon = new L.DivIcon({ className: 'map-icon opportunity', - html: '', + html: '', iconSize: [24, 24], // size of the icon iconAnchor: [12, 12], // point of the icon which will correspond to marker's location popupAnchor: [0, -12] // point from which the popup should open relative to the iconAnchor }); - {% endif %} - {% endif %} + */ {% endfor %} - // create markers, keep them in separate lists (by asset type) to be put into layers {% for asset_group_name in asset_groups %} - {% if asset_group_name not in aggregate_groups %} + {% if asset_group_name not in aggregate_type_groups %} + var {{ asset_groups[asset_group_name].parameterized_name }}_markers = []; + {% for asset in asset_groups[asset_group_name].assets if asset.location %} - - if (typeof marker_for_{{ (asset.name | parameterize) }} == 'undefined') { - var marker_for_{{ (asset.name | parameterize) + if (typeof marker_for_{{ (asset.id) }} == 'undefined') { + var marker_for_{{ (asset.id) }} = L .marker( [{{ asset.location[0] }}, {{ asset.location[1] }}], - { icon: {{ asset_groups[asset_group_name].parameterized_name }}_icon, id: "{{ asset.id }}"} + { icon: {{ asset.generic_asset_type.name | parameterize | pluralize }}_icon, id: "{{ asset.id }}"} ) .bindPopup(`
-

{{ asset.name }}

+

{{ asset.name }}

+ {% if user_has_admin_reader_rights %} Account: {{ asset.owner.name }} {% endif %}
@@ -176,7 +183,7 @@

{{ asset.name }}

next(); }); }) - .bindTooltip("{{ asset.name }}", + .bindTooltip({% if user_has_admin_reader_rights %}"{{ asset.name }} ({{ asset.owner.name }})"{% else %}"{{ asset.name }}" {% endif %}, { permanent: false, direction: 'right' @@ -184,7 +191,7 @@

{{ asset.name }}

.on('click', clickPan) .on('click', lookForState); // .openPopup(); - {{ asset_groups[asset_group_name].parameterized_name }}_markers.push(marker_for_{{ (asset.name | parameterize) }}); + {{ asset_groups[asset_group_name].parameterized_name }}_markers.push(marker_for_{{ (asset.id) }}); } {% endfor %} {% endif %} @@ -204,7 +211,7 @@

{{ asset.name }}

// add a layer for each asset type {% for asset_group_name in asset_groups %} - {% if asset_group_name not in aggregate_groups %} + {% if asset_group_name not in aggregate_type_groups %} {% if asset_groups[asset_group_name].count > 0 %} var {{ asset_groups[asset_group_name].parameterized_name }}_layer = new L.LayerGroup({{ asset_groups[asset_group_name].parameterized_name }}_markers); mcgLayerSupportGroup.checkIn({{ asset_groups[asset_group_name].parameterized_name }}_layer); diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index e660884cc..469e760a3 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -16,6 +16,7 @@ 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={}) asset_index = client.get(url_for("AssetCrudUI:index"), follow_redirects=True) assert asset_index.status_code == 200 @@ -98,6 +99,7 @@ def test_delete_asset(client, db, requests_mock, as_admin): """Delete an asset""" requests_mock.delete(f"{api_path_assets}/1", status_code=204, json={}) requests_mock.get(api_path_assets, status_code=200, json={}) + requests_mock.get(f"{api_path_assets}/public", status_code=200, json={}) response = client.get( url_for("AssetCrudUI:delete_with_data", id=1), follow_redirects=True, diff --git a/flexmeasures/ui/tests/test_views.py b/flexmeasures/ui/tests/test_views.py index 2e2276d58..4b17bb8ee 100644 --- a/flexmeasures/ui/tests/test_views.py +++ b/flexmeasures/ui/tests/test_views.py @@ -37,7 +37,7 @@ def test_assets_responds(client, requests_mock, as_prosumer_user1): ) assets_page = client.get(url_for("AssetCrudUI:index"), follow_redirects=True) assert assets_page.status_code == 200 - assert b"All assets" in assets_page.data + assert b"Asset overview" in assets_page.data def test_control_responds(client, as_prosumer_user1): diff --git a/flexmeasures/ui/utils/view_utils.py b/flexmeasures/ui/utils/view_utils.py index c6564ebd0..709cec52d 100644 --- a/flexmeasures/ui/utils/view_utils.py +++ b/flexmeasures/ui/utils/view_utils.py @@ -12,7 +12,7 @@ import iso8601 from flexmeasures import __version__ as flexmeasures_version -from flexmeasures.auth.policy import ADMIN_ROLE +from flexmeasures.auth.policy import user_has_admin_access from flexmeasures.utils import time_utils from flexmeasures.ui import flexmeasures_ui from flexmeasures.data.models.user import User, Account @@ -34,7 +34,7 @@ def render_flexmeasures_template(html_filename: str, **variables): variables["show_queues"] = False if current_user.is_authenticated: if ( - current_user.has_role(ADMIN_ROLE) + user_has_admin_access(current_user, "update") or current_app.config.get("FLEXMEASURES_MODE", "") == "demo" ): variables["show_queues"] = True @@ -80,9 +80,10 @@ def render_flexmeasures_template(html_filename: str, **variables): ) variables["user_is_logged_in"] = current_user.is_authenticated - variables[ - "user_is_admin" - ] = current_user.is_authenticated and current_user.has_role(ADMIN_ROLE) + variables["user_is_admin"] = user_has_admin_access(current_user, "update") + variables["user_has_admin_reader_rights"] = user_has_admin_access( + current_user, "read" + ) variables[ "user_is_anonymous" ] = current_user.is_authenticated and current_user.has_role("anonymous") diff --git a/flexmeasures/ui/views/new_dashboard.py b/flexmeasures/ui/views/new_dashboard.py index c030cf61e..d688d485d 100644 --- a/flexmeasures/ui/views/new_dashboard.py +++ b/flexmeasures/ui/views/new_dashboard.py @@ -2,10 +2,14 @@ from flask_security import login_required from flask_security.core import current_user from bokeh.resources import CDN +from flexmeasures.auth.policy import user_has_admin_access from flexmeasures.ui.views import flexmeasures_ui from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, clear_session -from flexmeasures.data.models.generic_assets import get_center_location_of_assets +from flexmeasures.data.models.generic_assets import ( + GenericAssetType, + get_center_location_of_assets, +) from flexmeasures.data.services.asset_grouping import ( AssetGroup, get_asset_group_queries, @@ -19,8 +23,8 @@ def new_dashboard_view(): """Dashboard view. This is the default landing page. It shows a map with the location of all of the assets in the user's account, - as well as a breakdown of the asset types. - Here, we are only interested in showing assets with power sensors. + or all assets if the user is an admin. + Assets are grouped by asset type, which leads to map layers and a table with asset counts by type. Admins get to see all assets. TODO: Assets for which the platform has identified upcoming balancing opportunities are highlighted. @@ -29,9 +33,17 @@ def new_dashboard_view(): if "clear-session" in request.values: clear_session() msg = "Your session was cleared." + aggregate_type_groups = current_app.config.get("FLEXMEASURES_ASSET_TYPE_GROUPS", {}) - aggregate_groups = current_app.config.get("FLEXMEASURES_ASSET_TYPE_GROUPS", {}) - asset_groups = get_asset_group_queries(custom_additional_groups=aggregate_groups) + group_by_accounts = request.args.get("group_by_accounts", "0") != "0" + if user_has_admin_access(current_user, "read") and group_by_accounts: + asset_groups = get_asset_group_queries( + group_by_type=False, group_by_account=True + ) + else: + asset_groups = get_asset_group_queries( + group_by_type=True, custom_aggregate_type_groups=aggregate_type_groups + ) map_asset_groups = {} for asset_group_name, asset_group_query in asset_groups.items(): @@ -39,6 +51,8 @@ def new_dashboard_view(): if any([a.location for a in asset_group.assets]): map_asset_groups[asset_group_name] = asset_group + known_asset_types = [gat.name for gat in GenericAssetType.query.all()] + # Pack CDN resources (from pandas_bokeh/base.py) bokeh_html_embedded = "" for css in CDN.css_files: @@ -54,6 +68,8 @@ def new_dashboard_view(): bokeh_html_embedded=bokeh_html_embedded, mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""), map_center=get_center_location_of_assets(user=current_user), + known_asset_types=known_asset_types, asset_groups=map_asset_groups, - aggregate_groups=aggregate_groups, + aggregate_type_groups=aggregate_type_groups, + group_by_accounts=group_by_accounts, )