diff --git a/documentation/changelog.rst b/documentation/changelog.rst index c1d4ca4148..8f992ccde5 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -24,6 +24,7 @@ New features * Added ``root`` and ``depth`` fields to the `[GET] /assets` endpoint for listing assets, to allow selecting descendants of a given root asset up to a given depth [see `PR #1874 `_] * Give ability to edit sensor timezone from the UI [see `PR #1900 `_] * Support creating schedules with only information known prior to some time, now also via the CLI (the API already supported it) [see `PR #1871 `_]. +* Added capability to upate an asset's parent from the UI [`PR #1957 `_] * Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 `_] .. note:: For backwards-compatibility, the new ``fields`` parameter will only be fully active, i.e. also returning less fields per default, in v0.32. Set ``FLEXMEASURES_API_SUNSET_ACTIVE=True`` to test the full effect now. diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 0e0710ca94..4a01ea311a 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -664,7 +664,6 @@ def fetch_one(self, id, asset): return asset_schema.dump(asset), 200 @route("/", methods=["PATCH"]) - @use_args(patch_asset_schema) @use_kwargs( { "db_asset": AssetIdField( @@ -675,7 +674,7 @@ def fetch_one(self, id, asset): ) @permission_required_for_context("update", ctx_arg_name="db_asset") @as_json - def patch(self, asset_data: dict, id: int, db_asset: GenericAsset): + def patch(self, id: int, db_asset: GenericAsset): """ .. :quickref: Assets; Update an asset given its identifier. --- @@ -733,10 +732,26 @@ def patch(self, asset_data: dict, id: int, db_asset: GenericAsset): tags: - Assets """ + # For us to be able to add our own context, we need to validate the data ourselves + asset_data = request.get_json() + if not asset_data: + return unprocessable_entity("No JSON data provided.") + + asset_schema = AssetSchema(partial=True) + asset_schema.context = { + "asset": db_asset + } # context for validating fields like parent_asset_id + + try: + validated_data = asset_schema.load(asset_data) + except ValidationError as e: + return unprocessable_entity(e.messages) + try: - db_asset = patch_asset(db_asset, asset_data) + db_asset = patch_asset(db_asset, validated_data) except ValidationError as e: - return unprocessable_entity(str(e.messages)) + return unprocessable_entity(e.messages) + db.session.add(db_asset) db.session.commit() return asset_schema.dump(db_asset), 200 diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index 16ca0a1ba6..06adba5553 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -209,17 +209,23 @@ def validate_name_is_unique_under_parent(self, data, **kwargs): Here, we can only check if we have all information (a full form), which usually is at creation time. """ - if "name" in data and "parent_asset_id" in data: - asset = db.session.scalars( - select(GenericAsset) - .filter_by( - name=data["name"], - parent_asset_id=data.get("parent_asset_id"), - ) - .limit(1) - ).first() - if asset: + if "name" in data: + parent_id = data.get("parent_asset_id", None) + + query = select(GenericAsset).filter_by( + name=data["name"], + parent_asset_id=parent_id, + ) + + current_editing_id = getattr(self, "context", {}).get("asset_id") + + if current_editing_id is not None: + query = query.filter(GenericAsset.id != current_editing_id) + + existing_asset = db.session.scalars(query).first() + + if existing_asset: err_msg = f"An asset with the name '{data['name']}' already exists under parent asset {data.get('parent_asset_id')}" raise ValidationError(err_msg, "name") @@ -233,11 +239,42 @@ def validate_generic_asset_type(self, generic_asset_type_id: int, **kwargs): @validates("parent_asset_id") def validate_parent_asset(self, parent_asset_id: int | None, **kwargs): - if parent_asset_id is not None: - parent_asset = db.session.get(GenericAsset, parent_asset_id) - if not parent_asset: + """ + Validate the `parent_asset_id`. + + Ensures the referenced parent GenericAsset exists. When editing an existing + asset (available in `self.context.get("asset")`), also ensures the parent + belongs to the same `Account` as the asset being edited. + + Parameters + ---------- + parent_asset_id : int | None + ID of the parent GenericAsset, or None. + + Raises + ------ + ValidationError + If the parent asset does not exist, or if the parent belongs to a different + account than the current asset. + """ + if parent_asset_id is None: + return + + parent_asset = db.session.get(GenericAsset, parent_asset_id) + if not parent_asset: + raise ValidationError( + f"Parent GenericAsset with id {parent_asset_id} doesn't exist." + ) + + # Check account consistency (using context) + # Safely get the asset from context, defaulting to None if creating new + current_asset = self.context.get("asset") + + # If editing an existing asset (context exists) + if current_asset and current_asset.account_id: + if parent_asset.account_id != current_asset.account_id: raise ValidationError( - f"Parent GenericAsset with id {parent_asset_id} doesn't exist." + "Parent asset must belong to the same account as the child asset." ) @validates("account_id") diff --git a/flexmeasures/ui/templates/assets/asset_properties.html b/flexmeasures/ui/templates/assets/asset_properties.html index c09b950b8e..e012fc2494 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -56,6 +56,23 @@

Edit {{ asset.name }}

{% endfor %} +
+ {{ asset_form.parent_asset_id.label(class="col-sm-6 control-label") }} +
+ + +
+
@@ -103,7 +120,7 @@

Edit {{ asset.name }}

{% endfor %}
- +
(Click map to edit latitude and longitude in form) @@ -120,12 +137,13 @@

Edit {{ asset.name }}

{% endif %} - +
{% if user_can_delete_asset %}
-
@@ -279,11 +297,8 @@

All child assets for {{ asset.name }}

- +
@@ -353,7 +368,8 @@
- +
@@ -412,7 +428,7 @@ // This variable is used to prevent reRenderForm from running on initial load let hasInitialized = false; - + const schemaSpecs = {{ flex_model_schema | tojson | safe }}; const assetFlexModelSchema = {}; const FlexModelFieldValidTypes = {}; @@ -436,7 +452,7 @@ FlexModelFieldValidTypes[key] = validTypes; } - const [assetFlexModelRawJSON, extraFields] = processResourceRawJSON( {...assetFlexModelSchema }, '{{ asset_flexmodel | safe }}', true); + const [assetFlexModelRawJSON, extraFields] = processResourceRawJSON({ ...assetFlexModelSchema }, '{{ asset_flexmodel | safe }}', true); document.addEventListener('DOMContentLoaded', async function () { const flexOptionsContainer = document.getElementById('flexOptionsContainer'); @@ -450,7 +466,7 @@ saveChangesBtn.onclick = async function () { await updateFlexModel(); }; - + const [getFlexModel, setFlexModel] = createReactiveState(assetFlexModelRawJSON, reRenderForm); const [activeCard, setActiveCard] = createReactiveState(null, () => { senSearchResEle.style.display = 'none'; }); const [selectedIndex, setSelectedIndex] = createReactiveState(null, renderFlexInputOptions); @@ -611,7 +627,7 @@ // Clean null values for (const [key, value] of Object.entries(data)) { - if ( + if ( value === null || value === "" || value === "Not Set" || @@ -1051,7 +1067,7 @@ input.className = 'form-control'; input.id = 'flexStringInput'; input.placeholder = 'Enter a value'; - input.value = `${(typeof value === 'string' && value !== "Not Set")? value : ''}`; + input.value = `${(typeof value === 'string' && value !== "Not Set") ? value : ''}`; input.onkeydown = function (event) { if (event.key === 'Enter') { event.preventDefault(); @@ -1164,7 +1180,7 @@ renderFlexModelForm(); // Initial render of flex model form renderFlexFieldOptions(assetFlexModelSchema, getFlexModel()); }); - + flexSelect.addEventListener('change', (event) => { const selectedKey = event.target.value; renderSelectInfoCards(selectedKey); @@ -1221,21 +1237,21 @@ } $(document).ready(function () { - let unit = ""; - // Initialize the DataTable - const table = $("#sensorsTable").dataTable({ - order: [[0, "asc"]], - pageLength: 5, - lengthMenu: [5, 10, 25, 50, 75, 100], - serverSide: true, - // make the table row vertically aligned with header - columns: [ - { data: "id", title: "ID", orderable: true }, - { data: "name", title: "Name", orderable: true }, - { data: "unit", title: "Unit", orderable: false }, - { data: "resolution", title: "Resolution", orderable: true }, - { data: "url", title: "URL", className: "d-none" }, - ], + let unit = ""; + // Initialize the DataTable + const table = $("#sensorsTable").dataTable({ + order: [[0, "asc"]], + pageLength: 5, + lengthMenu: [5, 10, 25, 50, 75, 100], + serverSide: true, + // make the table row vertically aligned with header + columns: [ + { data: "id", title: "ID", orderable: true }, + { data: "name", title: "Name", orderable: true }, + { data: "unit", title: "Unit", orderable: false }, + { data: "resolution", title: "Resolution", orderable: true }, + { data: "url", title: "URL", className: "d-none" }, + ], ajax: function (data, callback, settings) { const basePath = window.location.origin; @@ -1261,22 +1277,22 @@ - $.ajax({ - type: "get", - url: url, - success: function (response, text) { - let clean_response = []; - response["data"].forEach((element) => - clean_response.push( - new Sensor( - element["id"], - element["name"], - element["unit"], - element["event_resolution"], - element["active"] + $.ajax({ + type: "get", + url: url, + success: function (response, text) { + let clean_response = []; + response["data"].forEach((element) => + clean_response.push( + new Sensor( + element["id"], + element["name"], + element["unit"], + element["event_resolution"], + element["active"] + ) ) - ) - ); + ); callback({ data: clean_response, diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index ada0b4f022..6d053fbf17 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -129,6 +129,8 @@ def test_add_asset(db, client, setup_assets, as_admin): def test_edit_asset(db, client, setup_assets, as_admin): mock_asset = mock_asset_data_with_kpis(db=db, as_list=False) + mock_asset["name"] = "Edited name" + response = client.post( url_for("AssetCrudUI:post", id=1), follow_redirects=True, diff --git a/flexmeasures/ui/views/assets/forms.py b/flexmeasures/ui/views/assets/forms.py index 5e2107ab15..0d7e91be5d 100644 --- a/flexmeasures/ui/views/assets/forms.py +++ b/flexmeasures/ui/views/assets/forms.py @@ -37,6 +37,10 @@ class AssetForm(FlaskForm): places=None, render_kw={"placeholder": "--Click the map or enter a longitude--"}, ) + parent_asset_id = IntegerField( + "Parent Asset Id", + validators=[optional()], + ) attributes = StringField( "Other attributes (JSON)", default="{}", @@ -122,7 +126,6 @@ class NewAssetForm(AssetForm): "Asset type", coerce=int, validators=[DataRequired()] ) account_id = SelectField("Account", coerce=int, validators=[optional()]) - parent_asset_id = IntegerField("Parent Asset Id", validators=[optional()]) def set_account(self) -> tuple[Account | None, str | None]: """Set an account for the to-be-created asset. diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index daec0ed067..ef616e8e4c 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -292,6 +292,7 @@ def post(self, id: str): # noqa: C901 session["msg"] = f"Cannot edit asset: {asset_form.errors}" return redirect(url_for("AssetCrudUI:properties", id=id)) try: + patch_asset_schema.context = {"asset_id": asset.id} loaded_asset_data = patch_asset_schema.load(asset_form.to_json()) patch_asset(asset, loaded_asset_data) db.session.commit() @@ -399,9 +400,18 @@ def properties(self, id: str): from flexmeasures.data.schemas.scheduling import UI_FLEX_MODEL_SCHEMA + # suggestions for the parent asset selection + account_assets = ( + db.session.query(GenericAsset) + .filter_by(account_id=asset.account_id) + .order_by(GenericAsset.id.asc()) + .all() + ) + return render_flexmeasures_template( "assets/asset_properties.html", asset=asset, + account_assets=account_assets, site_asset=site_asset, flex_model_schema=UI_FLEX_MODEL_SCHEMA, asset_flexmodel=json.dumps(asset.flex_model),