diff --git a/documentation/changelog.rst b/documentation/changelog.rst index e271bd5501..f6b1aa63e2 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -61,6 +61,7 @@ New features * New ``GET /api/v3_0/sources`` endpoint to list accessible data sources and defined types, with ``only_latest=true`` by default to return only the most recent version per source [see `PR #2126 `_] * Add support for filtering sensor data GET requests by ``source-type`` on ``/api/v3_0/sensors//data`` [see `PR #2127 `_] * Making monitoring alerts more flexible: allow ``flexmeasures monitor`` alerts to target one or more user IDs or email addresses with ``--recipient``; ``flexmeasures monitor last-seen`` can now narrow monitored users to one or more accounts with ``--account`` or to client accounts with ``--consultancy`` [see `PR #2158 `_] +* Support for creating new assets by using another asset as a template from the UI. [see `PR #2195 `_] * Improve LightGBM daily seasonal lag handling for sub-hourly forecasting sensors [see `PR #2157 `_] Infrastructure / Support diff --git a/documentation/views/asset-data.rst b/documentation/views/asset-data.rst index b352f30307..6ccf0f55ae 100644 --- a/documentation/views/asset-data.rst +++ b/documentation/views/asset-data.rst @@ -56,37 +56,27 @@ Per asset, you can set fields in :ref:`the flex-context `, which w | -Editing an asset's flex-model + +Creating a new child asset ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Per asset, you can set fields in :ref:`the flex-model `. The flex model dialogue allows you to define a set or a single value type based on what the field allows, these include fixed values, sensors, floats, list (sensors or fixed values) and booleans. Initially, no fields are set. +From the context page, you can create a new child asset (you can also get there from the properties page, actually). +There are to ways to do that. -.. image:: https://github.com/FlexMeasures/screenshots/raw/main/screenshot_asset_flexmodel.png +A simple asset form, including a map selector: + +.. image:: https://github.com/FlexMeasures/screenshots/raw/main/screenshot_asset_new.png :align: center .. :scale: 40% | -Flex context overview -""""""""""""""""""""""" - -* **Left Panel:** Displays a list of currently configured fields. -* **Right Panel:** Shows details of the selected field and provides a form to modify its value. - -Adding a field -""""""""""""""" -1. **Select Field:** Choose the desired field from the dropdown menu in the top right corner of the modal. -2. **Add Field:** Click the "Add Field" button next to the dropdown. -3. The field will be added to the list in the left panel. - -Setting a field value -""""""""""""""""""""" - -1. **Select Field (if it is not selected yet):** Click on the field in the left panel. -2. **Save Value:** In the right panel, use the provided form to set the field's value. +You can also copy an exisiting asset, including its child assets, sensors and flex-config. +You can search for the asset you want to copy: - * Some fields may only accept a sensor value. - * Other fields may accept either a sensor or a fixed value. +.. image:: https://github.com/FlexMeasures/screenshots/raw/main/screenshot_asset_copy.png + :align: center +.. :scale: 40% | @@ -161,9 +151,9 @@ An example would be: Properties page --------------- -The properties page allows you to view and edit the properties of the asset. -It lists stored secret names without exposing their values and shows a -human-readable expiration time when one is available. +The properties page allows you to view and edit the properties of the asset, +including attributes, sensors and child assets. +Also, it lists stored secret names and expiration times without exposing their values. You can also delete the asset by clicking on the "Delete this asset" button. @@ -173,6 +163,39 @@ You can also delete the asset by clicking on the "Delete this asset" button. | +Editing an asset's flex-model +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Per asset, you can set fields in :ref:`the flex-model `. The flex model dialogue allows you to define a set or a single value type based on what the field allows, these include fixed values, sensors, floats, list (sensors or fixed values) and booleans. Initially, no fields are set. + +.. image:: https://github.com/FlexMeasures/screenshots/raw/main/screenshot_asset_flexmodel.png + :align: center +.. :scale: 40% + +| + +Flex context overview +""""""""""""""""""""""" + +* **Left Panel:** Displays a list of currently configured fields. +* **Right Panel:** Shows details of the selected field and provides a form to modify its value. + +Adding a field +""""""""""""""" +1. **Select Field:** Choose the desired field from the dropdown menu in the top right corner of the modal. +2. **Add Field:** Click the "Add Field" button next to the dropdown. +3. The field will be added to the list in the left panel. + +Setting a field value +""""""""""""""""""""" + +1. **Select Field (if it is not selected yet):** Click on the field in the left panel. +2. **Save Value:** In the right panel, use the provided form to set the field's value. + + * Some fields may only accept a sensor value. + * Other fields may accept either a sensor or a fixed value. + +| .. _view_asset_status: Status page diff --git a/flexmeasures/api/common/schemas/assets.py b/flexmeasures/api/common/schemas/assets.py index 8648b5517f..5b6b1f0bdf 100644 --- a/flexmeasures/api/common/schemas/assets.py +++ b/flexmeasures/api/common/schemas/assets.py @@ -3,7 +3,10 @@ from flexmeasures.api.common.schemas.generic_schemas import PaginationSchema from flexmeasures.api.common.schemas.users import AccountIdField from flexmeasures.data.schemas import AssetIdField -from flexmeasures.data.schemas.generic_assets import GenericAssetSchema +from flexmeasures.data.schemas.generic_assets import ( + AssetTypeIdField, + GenericAssetSchema, +) default_response_fields = ["id", "name", "account_id", "generic_asset_type"] @@ -92,6 +95,14 @@ class AssetAPIQuerySchema(PaginationSchema): example=False, ), ) + asset_type = AssetTypeIdField( + data_key="asset_type", + required=False, + metadata=dict( + description="Filter assets by generic asset type ID.", + example=2, + ), + ) class PublicAssetAPISchema(Schema): diff --git a/flexmeasures/api/common/utils/api_utils.py b/flexmeasures/api/common/utils/api_utils.py index 0e76f06875..2d7e43b48e 100644 --- a/flexmeasures/api/common/utils/api_utils.py +++ b/flexmeasures/api/common/utils/api_utils.py @@ -439,7 +439,7 @@ def _update_sensor_refs_in_subtree( def _copy_asset_subtree( source_asset: GenericAsset, - destination_account_id: int, + destination_account_id: int | None, destination_parent_asset_id: int | None, asset_schema: AssetSchema, add_copy_suffix: bool, @@ -462,6 +462,8 @@ def _copy_asset_subtree( ) asset_kwargs["account_id"] = destination_account_id asset_kwargs["parent_asset_id"] = destination_parent_asset_id + # set external_id to None to avoid conflicts with unique constraint on (account_id, external_id) + asset_kwargs["external_id"] = None asset_kwargs = convert_asset_json_fields(asset_kwargs) copied_asset = GenericAsset(**asset_kwargs) @@ -490,7 +492,7 @@ def _copy_asset_subtree( def _determine_copy_name( source_name: str, - destination_account_id: int, + destination_account_id: int | None, destination_parent_asset_id: int | None, ) -> str: """Return the next available copy name for the destination context. @@ -581,13 +583,19 @@ def copy_asset( asset_schema = AssetSchema() if account is None and parent_asset is None: - target_account_id = int(asset.account_id) + target_account_id = ( + int(asset.account_id) if asset.account_id is not None else None + ) target_parent_asset_id = asset.parent_asset_id elif account is not None and parent_asset is None: target_account_id = int(account.id) target_parent_asset_id = None elif account is None and parent_asset is not None: - target_account_id = int(parent_asset.account_id) + target_account_id = ( + int(parent_asset.account_id) + if parent_asset.account_id is not None + else None + ) target_parent_asset_id = int(parent_asset.id) else: target_account_id = int(account.id) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 11d7ddb441..4c66094d3e 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -275,6 +275,7 @@ def index( fields_in_response: list[str] | None, all_accessible: bool, include_public: bool, + asset_type: GenericAssetType | None = None, account: Account | None = None, root_asset: GenericAsset | None = None, max_depth: int | None = None, @@ -295,6 +296,7 @@ def index( - The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used. - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`. - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`. + - The `asset_type` query parameter can be used to filter by generic asset type ID. - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself). - The `depth` query parameter can be used to search only a max number of descendant generations from the root. @@ -371,6 +373,10 @@ def index( filter_statement = GenericAsset.account_id.in_(account_ids) if include_public: filter_statement = filter_statement | GenericAsset.account_id.is_(None) + if asset_type is not None: + filter_statement = filter_statement & ( + GenericAsset.generic_asset_type_id == asset_type.id + ) query = query_assets_by_search_terms( search_terms=filter, @@ -1784,10 +1790,20 @@ def copy_assets( # permission is sufficient (any account member may add children to an asset). # When creating a top-level asset (no parent), we fall back to the account-level # create-children check, which requires account-admin or consultant. + # Special case: public-to-public copies (both None) require site admin. if resolved_parent is not None: check_access(resolved_parent, "create-children") - else: + elif resolved_account is not None: check_access(resolved_account, "create-children") + else: + # Public asset copy (account_id=None, parent_asset_id=None). + # Only site admins may create public assets. + if not running_as_cli() and not user_has_admin_access( + current_user, "update" + ): + raise Forbidden( + "Only site admins may create public assets (account_id=None)." + ) try: new_asset = copy_asset(asset, account=account, parent_asset=parent_asset) diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 3e7cfcbd5e..85485d2059 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -170,6 +170,37 @@ def test_get_assets( assert turbine["account_id"] == setup_accounts["Supplier"].id +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_get_assets_filtered_by_asset_type( + client, setup_api_test_data, setup_accounts, requesting_user +): + supplier_account = setup_accounts["Supplier"] + supplier_account_id = supplier_account.id + supplier_assets = supplier_account.generic_assets + + requested_type_id = supplier_assets[0].generic_asset_type_id + expected_assets = [ + asset + for asset in supplier_assets + if asset.generic_asset_type_id == requested_type_id + ] + + response = client.get( + url_for("AssetAPI:index"), + query_string={ + "account_id": supplier_account_id, + "asset_type": requested_type_id, + }, + ) + + assert response.status_code == 200 + assert len(response.json) == len(expected_assets) + assert all( + asset["generic_asset_type"]["id"] == requested_type_id + for asset in response.json + ) + + @pytest.mark.parametrize( "requesting_user, sort_by, sort_dir, expected_name_of_first_sensor", [ diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index baf3807058..f09ad37d48 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -549,6 +549,24 @@ class Meta: model = GenericAssetType +class AssetTypeIdField(MarshmallowClickMixin, fields.Int): + """Field that deserializes to a GenericAssetType and serializes back to an integer.""" + + def _deserialize(self, value: Any, attr, data, **kwargs) -> GenericAssetType: + """Turn a generic asset type id into a GenericAssetType.""" + asset_type_id: int = super()._deserialize(value, attr, data, **kwargs) + asset_type = db.session.get(GenericAssetType, asset_type_id) + if asset_type is None: + raise FMValidationError( + f"No generic asset type found with id {asset_type_id}." + ) + return asset_type + + def _serialize(self, value: GenericAssetType, attr, obj, **kwargs): + """Turn a GenericAssetType into a generic asset type id.""" + return value.id + + class GenericAssetIdField(MarshmallowClickMixin, fields.Int): """Field that deserializes to a GenericAsset and serializes back to an integer.""" diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index af48a5cc66..67ceae5c14 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -3477,7 +3477,7 @@ "/api/v3_0/assets": { "get": { "summary": "List assets accessible by the user.", - "description": "This endpoint returns all assets that are accessible by the user after applying optional filters.\n\n - The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used.\n - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`.\n - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`.\n - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself).\n - The `depth` query parameter can be used to search only a max number of descendant generations from the root.\n\nThe endpoint supports pagination of the asset list using the `page` and `per_page` query parameters.\n - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will return only assets where each search term is either present in their name or account name, or is a prefix of their ID.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n\nPer default, the response only includes a limited set of asset fields (id, name, account_id, generic_asset_type).\nYou can use the `fields` query parameter to specify a custom set of fields to include in the response.\n", + "description": "This endpoint returns all assets that are accessible by the user after applying optional filters.\n\n - The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used.\n - Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`.\n - The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`.\n - The `asset_type` query parameter can be used to filter by generic asset type ID.\n - The `root` query parameter can be used to list only descendants of a given root asset (including the root itself).\n - The `depth` query parameter can be used to search only a max number of descendant generations from the root.\n\nThe endpoint supports pagination of the asset list using the `page` and `per_page` query parameters.\n - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets.\n - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).\n - If a search 'filter' such as 'solar \"ACME corp\"' is provided, the response will return only assets where each search term is either present in their name or account name, or is a prefix of their ID.\n The response schema for pagination is inspired by [DataTables](https://datatables.net/manual/server-side#Returned-data)\n\nPer default, the response only includes a limited set of asset fields (id, name, account_id, generic_asset_type).\nYou can use the `fields` query parameter to specify a custom set of fields to include in the response.\n", "security": [ { "ApiKeyAuth": [] @@ -3602,6 +3602,16 @@ "example": false }, "required": false + }, + { + "in": "query", + "name": "asset_type", + "description": "Filter assets by generic asset type ID.", + "schema": { + "type": "integer", + "example": 2 + }, + "required": false } ], "responses": { @@ -4910,6 +4920,11 @@ "default": false, "description": "Whether to include public assets. Ignored if an `account_id` is set. To fetch only public assets, use [/assets/public/](#/Assets/get_api_v3_0_assets_public) instead.", "example": false + }, + "asset_type": { + "type": "integer", + "description": "Filter assets by generic asset type ID.", + "example": 2 } }, "additionalProperties": false diff --git a/flexmeasures/ui/templates/assets/asset_new.html b/flexmeasures/ui/templates/assets/asset_new.html index e7ba554fa2..74c63beb68 100644 --- a/flexmeasures/ui/templates/assets/asset_new.html +++ b/flexmeasures/ui/templates/assets/asset_new.html @@ -7,9 +7,28 @@ {% block divs %}
- +

Creating a new asset {% if parent_asset_name %} under asset {{ parent_asset_name }} {% endif %}

- + + + + +
+ + +
+
{{ asset_form.csrf_token }} {{ asset_form.hidden_tag() }} @@ -108,6 +127,54 @@

Location

+ +
+ + +
+ +
+
+ +
+
+ +
+
+ {% if not current_user.has_role('admin') %} +
+ + +
+ {% else %} + + {% endif %} +
+
+ +
+
+ Loading… +
+
+ +
+
+
+
+ +
+ +
+ +
+ @@ -174,4 +241,300 @@

Location

+ + {% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index 23bcc0733e..f580a064e7 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from sqlalchemy import select from flask import redirect, url_for, current_app, request, session from flask_classful import FlaskView, route from flask_security import login_required, current_user @@ -18,6 +19,7 @@ ) from flexmeasures.data.models.generic_assets import ( GenericAsset, + GenericAssetType, get_bounding_box_of_assets, ) from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema @@ -148,6 +150,10 @@ def get(self, id: str, **kwargs): if account: # Pre-set account asset_form.account_id.data = str(account.id) + asset_types = db.session.scalars( + select(GenericAssetType).order_by(GenericAssetType.name) + ).all() + return render_flexmeasures_template( "assets/asset_new.html", asset_form=asset_form, @@ -158,6 +164,7 @@ def get(self, id: str, **kwargs): parent_asset_name=parent_asset_name, parent_asset_id=parent_asset_id, account=account, + asset_types=asset_types, ) # otherwise, redirect to the default asset view