diff --git a/flexmeasures/api/common/utils/api_utils.py b/flexmeasures/api/common/utils/api_utils.py index 5b70551ef..3b8ced436 100644 --- a/flexmeasures/api/common/utils/api_utils.py +++ b/flexmeasures/api/common/utils/api_utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +from copy import deepcopy +import json from timely_beliefs.beliefs.classes import BeliefsDataFrame from typing import Sequence from datetime import timedelta @@ -14,6 +16,8 @@ from flexmeasures.data import db from flexmeasures.data.models.user import Account +from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.utils import save_to_db from flexmeasures.auth.policy import check_access from flexmeasures.api.common.responses import ( @@ -22,6 +26,7 @@ request_processed, already_received_and_successfully_processed, ) +from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema from flexmeasures.utils.error_utils import error_handling_router from flexmeasures.utils.flexmeasures_inflection import capitalize @@ -182,3 +187,143 @@ def get_accessible_accounts() -> list[Account]: pass return accounts + + +def convert_asset_json_fields(asset_kwargs): + """ + Convert string fields in asset_kwargs to JSON where needed. + """ + if "attributes" in asset_kwargs and isinstance(asset_kwargs["attributes"], str): + asset_kwargs["attributes"] = json.loads(asset_kwargs["attributes"]) + if "sensors_to_show" in asset_kwargs and isinstance( + asset_kwargs["sensors_to_show"], str + ): + asset_kwargs["sensors_to_show"] = json.loads(asset_kwargs["sensors_to_show"]) + if "flex_context" in asset_kwargs and isinstance(asset_kwargs["flex_context"], str): + asset_kwargs["flex_context"] = json.loads(asset_kwargs["flex_context"]) + if "flex_model" in asset_kwargs and isinstance(asset_kwargs["flex_model"], str): + asset_kwargs["flex_model"] = json.loads(asset_kwargs["flex_model"]) + if "sensors_to_show_as_kpis" in asset_kwargs and isinstance( + asset_kwargs["sensors_to_show_as_kpis"], str + ): + asset_kwargs["sensors_to_show_as_kpis"] = json.loads( + asset_kwargs["sensors_to_show_as_kpis"] + ) + return asset_kwargs + + +def _copy_direct_sensors( + source_asset: GenericAsset, copied_asset: GenericAsset +) -> None: + """Copy sensors directly attached to one asset.""" + source_sensors = db.session.scalars( + select(Sensor).filter(Sensor.generic_asset_id == source_asset.id) + ).all() + for source_sensor in source_sensors: + sensor_kwargs = {} + for column in source_sensor.__table__.columns: + if column.name in [ + "id", + "generic_asset_id", + "knowledge_horizon_fnc", + "knowledge_horizon_par", + ]: + continue + sensor_kwargs[column.name] = deepcopy(getattr(source_sensor, column.name)) + + sensor_kwargs["generic_asset_id"] = copied_asset.id + + db.session.add(Sensor(**sensor_kwargs)) + + +def _copy_asset_subtree( + source_asset: GenericAsset, + destination_account_id: int, + destination_parent_asset_id: int | None, + asset_schema: AssetSchema, +) -> GenericAsset: + """Recursively copy one asset and all descendants.""" + asset_kwargs = asset_schema.dump(source_asset) + + for key in ["id", "owner", "generic_asset_type", "child_assets", "sensors"]: + asset_kwargs.pop(key, None) + + asset_kwargs["name"] = f"{asset_kwargs['name']} (Copy)" + asset_kwargs["account_id"] = destination_account_id + asset_kwargs["parent_asset_id"] = destination_parent_asset_id + asset_kwargs = convert_asset_json_fields(asset_kwargs) + + copied_asset = GenericAsset(**asset_kwargs) + db.session.add(copied_asset) + db.session.flush() + + _copy_direct_sensors(source_asset, copied_asset) + + source_children = db.session.scalars( + select(GenericAsset) + .filter(GenericAsset.parent_asset_id == source_asset.id) + .order_by(GenericAsset.id) + ).all() + for source_child in source_children: + _copy_asset_subtree( + source_asset=source_child, + destination_account_id=destination_account_id, + destination_parent_asset_id=copied_asset.id, + asset_schema=asset_schema, + ) + + return copied_asset + + +def copy_asset( + asset: GenericAsset, + account=None, + parent_asset=None, +) -> GenericAsset: + """ + Copy an asset subtree to a target account and/or under a target parent asset. + + The copied subtree includes: + - the selected asset + - all descendant child assets (recursively) + - all sensors directly attached to each copied asset + + Resolution rules: + + - If neither ``account`` nor ``parent_asset`` is given, the copy is placed in + the same account and under the same parent as the original (i.e. a sibling). + - If ``account`` is given but ``parent_asset`` is not, the copy becomes a + top-level asset (no parent) in the given account. + - If ``parent_asset`` is given but ``account`` is not, the copy is placed under + the given parent and inherits that parent's account. + - If both are given, the copy belongs to the given account and is placed under + the given parent. This allows creating a copy that belongs to a different + account than its parent. + """ + try: + asset_schema = AssetSchema() + + if account is None and parent_asset is None: + target_account_id = int(asset.account_id) + 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_parent_asset_id = int(parent_asset.id) + else: + target_account_id = int(account.id) + target_parent_asset_id = int(parent_asset.id) + + copied_root = _copy_asset_subtree( + source_asset=asset, + destination_account_id=target_account_id, + destination_parent_asset_id=target_parent_asset_id, + asset_schema=asset_schema, + ) + db.session.commit() + return copied_root + except Exception as e: + db.session.rollback() + raise e diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 4a01ea311..12833749d 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -1,4 +1,6 @@ from __future__ import annotations + +from typing import Any import json from datetime import datetime, timedelta from http import HTTPStatus @@ -11,7 +13,7 @@ from flask_json import as_json from flask_sqlalchemy.pagination import SelectPagination -from marshmallow import fields, ValidationError, Schema, validate +from marshmallow import fields, post_load, ValidationError, Schema, validate from webargs.flaskparser import use_kwargs, use_args from sqlalchemy import select, func, or_ @@ -56,7 +58,10 @@ create_sequential_scheduling_job, create_simultaneous_scheduling_job, ) -from flexmeasures.api.common.utils.api_utils import get_accessible_accounts +from flexmeasures.api.common.utils.api_utils import ( + get_accessible_accounts, + copy_asset, +) from flexmeasures.api.common.responses import ( unprocessable_entity, request_processed, @@ -159,6 +164,59 @@ class KPIKwargsSchema(Schema): event_ends_before = AwareDateTimeField(format="iso", required=False) +class CopyAssetSchema(Schema): + account = AccountIdField( + data_key="account_id", + required=False, + metadata=dict( + description="Target account to copy the asset to.", + example=67, + ), + ) + parent_asset = AssetIdField( + data_key="parent_id", + required=False, + metadata=dict( + description="Target parent asset to copy the asset under.", + example=482, + ), + ) + + @post_load + def resolve_account_and_parent(self, data, **kwargs): + """ + Resolve the account/parent relationship after loading: + + - If ``account`` is explicitly given but ``parent_asset`` is not, the copy + becomes a top-level asset (no parent) in the given account. + - If ``parent_asset`` is explicitly given but ``account`` is not, the copy + inherits the account of the parent asset. + - If both are explicitly given, the copy can belong to a different account + than its parent, which is a valid cross-account parent relationship. + - If neither is given, the original asset's account and parent are preserved. + """ + account_given = "account" in data + parent_given = "parent_asset" in data + + if account_given and not parent_given: + data["parent_asset"] = None + elif parent_given and not account_given: + data["account"] = data["parent_asset"].owner + + # Resolve effective targets for permission checks and fallback behavior. + # If neither target is given, use the source asset's account/parent. + source_asset: GenericAsset | None = self.context.get("asset") + if source_asset is not None: + if data.get("account") is None: + data["resolved_account"] = source_asset.owner + data["resolved_parent"] = source_asset.parent_asset + else: + data["resolved_account"] = data["account"] + data["resolved_parent"] = data.get("parent_asset") + + return data + + class AssetTypesAPI(FlaskView): """ This API view exposes generic asset types. @@ -301,13 +359,14 @@ def index( check_access(account, "read") account_ids = [account.id] else: - use_all_accounts = all_accessible or root_asset - include_public = all_accessible or include_public or root_asset - account_ids = ( - [a.id for a in get_accessible_accounts()] - if use_all_accounts - else [current_user.account.id] + use_all_accounts = all_accessible or (root_asset is not None) + include_public = ( + all_accessible or include_public or (root_asset is not None) ) + if use_all_accounts: + account_ids = [a.id for a in get_accessible_accounts()] + else: + account_ids = [current_user.account.id] filter_statement = GenericAsset.account_id.in_(account_ids) if include_public: filter_statement = filter_statement | GenericAsset.account_id.is_(None) @@ -441,6 +500,9 @@ def asset_sensors( tags: - Assets """ + if asset is None: + return unprocessable_entity("No asset found for the given id.") + query_statement = Sensor.generic_asset_id == asset.id query = select(Sensor).filter(query_statement) @@ -1542,3 +1604,44 @@ def get_kpis(self, id: int, asset: GenericAsset, start, end): } kpis.append(kpi_dict) return dict(data=kpis), 200 + + @route("//copy", methods=["POST"]) + @use_kwargs( + { + "asset": AssetIdField( + data_key="id", status_if_not_found=HTTPStatus.NOT_FOUND + ) + }, + location="path", + ) + @as_json + def copy_assets(self, id, asset: GenericAsset): + """ + .. :quickref: Assets; Copy an asset to a target account and/or parent. + """ + copy_asset_schema: Any = CopyAssetSchema() + copy_asset_schema.context["asset"] = asset + + try: + copy_data = copy_asset_schema.load(request.args) + except ValidationError as e: + return unprocessable_entity(str(e.messages)) + + account = copy_data.get("account") + parent_asset = copy_data.get("parent_asset") + resolved_account = copy_data["resolved_account"] + resolved_parent = copy_data["resolved_parent"] + + # Check create-children permission on the target account. + check_access(resolved_account, "create-children") + + # Also check create-children permission on the target parent (if any). + if resolved_parent is not None: + check_access(resolved_parent, "create-children") + + new_asset = copy_asset(asset, account=account, parent_asset=parent_asset) + + return { + "message": f"Successfully copied asset {asset.id} to account {new_asset.account_id}.", + "asset": new_asset.id, + }, 201 diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index b762cd21b..669b95e31 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -1,4 +1,5 @@ import json +from datetime import timedelta from flask import url_for import pytest @@ -6,9 +7,11 @@ from flexmeasures.data.models.audit_log import AssetAuditLog from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.services.users import find_user_by_email from flexmeasures.api.tests.utils import get_auth_token, UserContext, AccountContext from flexmeasures.api.v3_0.tests.utils import get_asset_post_data, check_audit_log_event +from flexmeasures.api.common.utils.api_utils import copy_asset from flexmeasures.utils.unit_utils import is_valid_unit @@ -685,3 +688,250 @@ def test_consultant_get_asset( print("Server responded with:\n%s" % get_asset_response.json) assert get_asset_response.status_code == 200 assert get_asset_response.json["name"] == "Test ConsultancyClient Asset" + + +def test_copy_asset(setup_api_test_data, setup_accounts, db): + """ + Test all four placement use cases for copy_asset and verify direct sensors are copied. + + 1. Neither account nor parent given → same account, same parent (sibling copy). + 2. Only account given → top-level asset in the given account. + 3. Only parent given → under the parent, inheriting its account. + 4. Both account and parent given → under the parent, in the given account + (cross-account parent relationship allowed). + """ + prosumer_account = setup_accounts["Prosumer"] + supplier_account = setup_accounts["Supplier"] + + # Source assets created by setup_generic_assets (via setup_api_test_data dependency) + battery = db.session.scalars( + select(GenericAsset).filter_by( + account_id=prosumer_account.id, + name="Test grid connected battery storage", + ) + ).first() + turbine = db.session.scalars( + select(GenericAsset).filter_by(name="Test wind turbine") + ).first() + + assert battery is not None, "Battery asset must exist in Prosumer account" + assert turbine is not None, "Wind turbine asset must exist in Supplier account" + + # Add a deterministic sensor on battery so we can verify deep copy behavior. + source_sensor = Sensor( + name="copy-source-sensor", + generic_asset=battery, + event_resolution=timedelta(minutes=15), + unit="kW", + ) + db.session.add(source_sensor) + db.session.flush() + + # Create a parent asset in the Supplier account for use cases 3 and 4. + parent = GenericAsset( + name="Test parent for copy", + generic_asset_type=battery.generic_asset_type, + owner=supplier_account, + ) + db.session.add(parent) + db.session.flush() + + # 1. Neither given → sibling copy (same account, same parent) + copy1 = copy_asset(battery) + assert copy1.name == f"{battery.name} (Copy)" + assert copy1.account_id == battery.account_id + assert copy1.parent_asset_id == battery.parent_asset_id # None + + copied_sensor = db.session.scalars( + select(Sensor).filter( + Sensor.generic_asset_id == copy1.id, + Sensor.name == source_sensor.name, + ) + ).first() + assert copied_sensor is not None + assert copied_sensor.unit == source_sensor.unit + assert copied_sensor.event_resolution == source_sensor.event_resolution + + # 2. Only account given → top-level in target account + # Use the turbine so the name doesn't clash with copy1 (parent_asset_id is None for both). + copy2 = copy_asset(turbine, account=prosumer_account) + assert copy2.name == f"{turbine.name} (Copy)" + assert copy2.account_id == prosumer_account.id + assert copy2.parent_asset_id is None + + # 3. Only parent given → under parent, inherits parent's account + copy3 = copy_asset(battery, parent_asset=parent) + assert copy3.name == f"{battery.name} (Copy)" + assert copy3.account_id == parent.account_id # Supplier account + assert copy3.parent_asset_id == parent.id + + # 4. Both given → under parent, in explicitly given account (cross-account) + copy4 = copy_asset(turbine, account=prosumer_account, parent_asset=parent) + assert copy4.name == f"{turbine.name} (Copy)" + assert copy4.account_id == prosumer_account.id + assert copy4.parent_asset_id == parent.id + + +def test_copy_asset_fails_on_duplicate_name_under_same_parent( + setup_api_test_data, setup_accounts, db +): + """ + Copying the same asset twice under the same parent raises an IntegrityError. + + The DB enforces UNIQUE(name, parent_asset_id). The first copy succeeds + producing e.g. 'Battery (Copy)' under the given parent. The second copy + tries to insert another row with the exact same (name, parent_asset_id) + pair, which violates the constraint. + """ + from sqlalchemy.exc import IntegrityError + + prosumer_account = setup_accounts["Prosumer"] + battery = db.session.scalars( + select(GenericAsset).filter_by( + account_id=prosumer_account.id, + name="Test grid connected battery storage", + ) + ).first() + assert battery is not None + + # Create a dedicated parent so this test is independent of others. + parent = GenericAsset( + name="Test parent for duplicate-name failure", + generic_asset_type_id=battery.generic_asset_type_id, + account_id=prosumer_account.id, + ) + db.session.add(parent) + db.session.flush() + + # First copy under the parent succeeds. + first_copy = copy_asset(battery, parent_asset=parent) + assert first_copy.parent_asset_id == parent.id + + # Second copy under the same parent fails: UNIQUE(name, parent_asset_id) is violated + # because parent_asset_id is non-NULL (PostgreSQL only treats NULLs as distinct). + with pytest.raises(IntegrityError): + copy_asset(battery, parent_asset=parent) + + +def test_copy_asset_to_another_account_preserves_config( + setup_api_test_data, setup_accounts, setup_markets, setup_generic_asset_types, db +): + """ + Copy a richly configured asset from one account to another and verify everything + is preserved correctly. + + Source asset layout (Prosumer account): + + House (EMS) + ├── flex_context: + │ - consumption-price → sensor on the public epex asset (no account) + │ - site-power-capacity → sensor on the House itself (kW capacity) + ├── EV charger 1 (child) + │ - flex_model: { "power-capacity": "7.4 kW", "soc-unit": "kWh" } + │ - sensors: power (kW), energy (kWh) + └── EV charger 2 (child) + - flex_model: { "power-capacity": "7.4 kW", "soc-unit": "kWh" } + - sensors: power (kW), energy (kWh) + + Assertions after copying House to the Supplier account: + 1. The copy lands in the Supplier account with the expected name. + 2. The copy is a top-level asset (no parent). + 3. flex_context is preserved verbatim (sensor IDs are unchanged). + 4. copy_asset performs a deep subtree copy: child assets are duplicated. + 5. Sensors on copied child assets are duplicated. + """ + prosumer_account = setup_accounts["Prosumer"] + supplier_account = setup_accounts["Supplier"] + + # The epex_da sensor lives on the public "epex" asset (account_id=None). + price_sensor = setup_markets["epex_da"] + assert price_sensor.generic_asset.account_id is None, "epex must be a public asset" + + asset_type = setup_generic_asset_types["battery"] + charger_type = setup_generic_asset_types["wind"] + + # Build the source house asset. + house = GenericAsset( + name="Test house for rich copy", + generic_asset_type=asset_type, + owner=prosumer_account, + ) + db.session.add(house) + db.session.flush() # obtain house.id before adding sensors + + # A kW sensor on the house itself, referenced as the site-power-capacity. + site_capacity_sensor = Sensor( + name="site capacity", + generic_asset=house, + event_resolution=timedelta(minutes=15), + unit="kW", + ) + db.session.add(site_capacity_sensor) + db.session.flush() + + house.flex_context = { + "consumption-price": {"sensor": price_sensor.id}, + "site-power-capacity": {"sensor": site_capacity_sensor.id}, + } + + # Two child assets, each with two sensors and a two-setting flex_model. + for i in range(1, 3): + charger = GenericAsset( + name=f"EV charger {i}", + generic_asset_type=charger_type, + owner=prosumer_account, + parent_asset_id=house.id, + flex_model={"power-capacity": "7.4 kW", "soc-unit": "kWh"}, + ) + db.session.add(charger) + db.session.flush() + for j, unit in enumerate(["kW", "kWh"], start=1): + db.session.add( + Sensor( + name=f"charger {i} sensor {j}", + generic_asset=charger, + event_resolution=timedelta(minutes=15), + unit=unit, + ) + ) + db.session.flush() + + original_flex_context = house.flex_context.copy() + + # --- Act --- + house_copy = copy_asset(house, account=supplier_account) + + # 1. Correct account and name. + assert house_copy.account_id == supplier_account.id + assert house_copy.name == f"{house.name} (Copy)" + + # 2. Top-level in the target account (no parent given → parent_asset_id = None). + assert house_copy.parent_asset_id is None + + # 3. flex_context is preserved verbatim. + assert house_copy.flex_context == original_flex_context + + # 4. Direct sensors on the copied asset are duplicated. + copied_house_sensors = db.session.scalars( + select(Sensor).filter(Sensor.generic_asset_id == house_copy.id) + ).all() + assert len(copied_house_sensors) == 1 + assert copied_house_sensors[0].name == site_capacity_sensor.name + assert copied_house_sensors[0].unit == site_capacity_sensor.unit + assert ( + copied_house_sensors[0].event_resolution + == site_capacity_sensor.event_resolution + ) + + # 5. Deep copy for hierarchy: original child assets are duplicated. + children_of_copy = db.session.scalars( + select(GenericAsset).filter_by(parent_asset_id=house_copy.id) + ).all() + assert len(children_of_copy) == 2 + + # 6. Sensors on copied child assets are also duplicated. + copied_child_ids = [child.id for child in children_of_copy] + copied_child_sensors = db.session.scalars( + select(Sensor).filter(Sensor.generic_asset_id.in_(copied_child_ids)) + ).all() + assert len(copied_child_sensors) == 4 diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 4507b147e..cb6ce0349 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -2648,6 +2648,7 @@ ] } }, + "/api/v3_0/assets/{id}/copy": {}, "/api/v3_0/assets/{id}": { "delete": { "summary": "Delete an asset.", @@ -3898,14 +3899,7 @@ "schemas": { "Quantity": { "type": "string", - "description": "Quantity string describing a fixed quantity.", - "examples": [ - "130 EUR/MWh", - "12 V", - "4.5 m/s", - "20 \u00b0C", - "3 * 230V * 16A" - ] + "description": "Quantity string describing a fixed quantity." }, "SensorReference": { "type": "object",