Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a886a66
chore: initialize repo
joshuaunity Mar 5, 2026
5fc6e42
chore: work in progress
joshuaunity Mar 9, 2026
09e879f
tests: added test to test for cpy asset feature
joshuaunity Mar 11, 2026
f588302
Merge branch 'main' of github.com:FlexMeasures/flexmeasures into feat…
joshuaunity Mar 11, 2026
3f97255
chore: removed unused file
joshuaunity Mar 11, 2026
d609e37
refactor: relocate util function
joshuaunity Mar 11, 2026
abd590b
refacto: Refactored util for copying asset
joshuaunity Mar 13, 2026
fb39eac
tests: and new test case
joshuaunity Mar 13, 2026
71810d0
Merge branch 'main' into feat/copy-assets
joshuaunity Mar 16, 2026
5371a4e
chore: move logic into @post_load
joshuaunity Mar 16, 2026
e84899e
feat: implement deep copy of asset subtree including direct sensors
joshuaunity Mar 17, 2026
510fa71
feat: add permission requirement for copying assets
joshuaunity Mar 18, 2026
bb6e959
feat: enhance asset copy endpoint with detailed OpenAPI specification…
joshuaunity Mar 18, 2026
9952e5b
feat: add test for asset copy API to ensure direct sensors are duplic…
joshuaunity Mar 18, 2026
d9eacf7
Update flexmeasures/api/v3_0/assets.py
joshuaunity Mar 18, 2026
a237a40
feat: enhance asset copy response messages for clarity based on param…
joshuaunity Mar 19, 2026
1a6c4c4
Merge branch 'feat/copy-assets' of github.com:FlexMeasures/flexmeasur…
joshuaunity Mar 19, 2026
8cf58f6
Merge branch 'main' into feat/copy-assets
joshuaunity Mar 19, 2026
3b88319
Merge branch 'main' into feat/copy-assets
joshuaunity Mar 25, 2026
46fc632
Revert same-site sensor reference preservation in asset copy (#2056)
Copilot Mar 30, 2026
54f83ca
Refactor asset copy parameters in API and OpenAPI specs to use 'accou…
joshuaunity Mar 30, 2026
4e84d1f
Merge branch 'main' into feat/copy-assets
joshuaunity Apr 1, 2026
67446b6
fix: improve sensor reference handling and format validation in asset…
joshuaunity Apr 7, 2026
b195085
feat: add audit log entry for asset copying in copy_asset function
joshuaunity Apr 7, 2026
04c25ee
feat: add option to include copy suffix when duplicating assets
joshuaunity Apr 9, 2026
3b8dd82
fix: restore post_annotation function lost during merge, fixing unuse…
Copilot Apr 10, 2026
ca4c390
feat: implement incremental naming for asset copies and add new API e…
joshuaunity Apr 13, 2026
8769a5d
chore: added changelog entry
joshuaunity Apr 13, 2026
a1b807f
Merge branch 'main' into feat/copy-assets
joshuaunity Apr 13, 2026
a2b86ab
Merge branch 'main' into feat/copy-assets
joshuaunity Apr 13, 2026
47a95bd
feat: add support for numeric graph format in asset graph template
joshuaunity Apr 13, 2026
640071a
feat: add validation to prevent copying an asset to itself or its des…
joshuaunity Apr 14, 2026
14cb654
fix: correct response message structure in asset copy rejection tests
joshuaunity Apr 14, 2026
1ac2117
Merge branch 'main' into feat/copy-assets
joshuaunity Apr 15, 2026
0a9090a
refactor: remove unnecessary asset existence check in AssetAPI
joshuaunity Apr 17, 2026
d463d3e
Merge remote-tracking branch 'origin/main' into feat/copy-assets
Flix6x Apr 17, 2026
e28aa47
chore: move changelog entry to v0.33.0
Flix6x Apr 17, 2026
383ee0a
refactor: cross-reference the CopyAssetSchema to get its field descri…
Flix6x Apr 21, 2026
2a5daea
feat: simplify endpoint description
Flix6x Apr 21, 2026
233b206
fix: drop int32 claim, which is unenforced at the Python/marshmallow …
Flix6x Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions flexmeasures/api/common/utils/api_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from timely_beliefs.beliefs.classes import BeliefsDataFrame
from typing import Sequence
from datetime import timedelta
Expand All @@ -14,6 +15,7 @@

from flexmeasures.data import db
from flexmeasures.data.models.user import Account
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.utils import save_to_db
from flexmeasures.auth.policy import check_access
from flexmeasures.api.common.responses import (
Expand All @@ -22,6 +24,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

Expand Down Expand Up @@ -182,3 +185,83 @@ 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 fetch_and_copy_all_assets_in_account(
account_id: int, target_account_id: int
) -> list[GenericAsset]:
try:
asset_schema = AssetSchema()

# order from oldest to newest to help with parent/child dependencies
assets = db.session.scalars(
select(GenericAsset)
.filter(GenericAsset.account_id == account_id)
.order_by(GenericAsset.id)
).all()

if len(assets) == 0:
raise ValueError(f"No assets found for account {account_id}.")

asset_mapping = {}
parent_mapping = {}
new_assets = []

for old_asset in assets:
asset_kwargs = asset_schema.dump(old_asset)

# Remove dump_only and read-only fields
for key in ["id", "owner", "generic_asset_type", "child_assets", "sensors"]:
asset_kwargs.pop(key, None)

# Avoid name collisions
asset_kwargs["name"] = f"{asset_kwargs['name']} (Copy)"
# Assign to the target account
asset_kwargs["account_id"] = target_account_id
asset_kwargs = convert_asset_json_fields(asset_kwargs)

# Keep track of parent_asset_id to reconnect later
if asset_kwargs.get("parent_asset_id"):
parent_mapping[old_asset.id] = asset_kwargs["parent_asset_id"]
asset_kwargs["parent_asset_id"] = None

new_asset = GenericAsset(**asset_kwargs)
db.session.add(new_asset)
db.session.flush()

asset_mapping[old_asset.id] = new_asset
new_assets.append(new_asset)

# Second loop to set the proper parent
for old_id, old_parent_id in parent_mapping.items():
if old_parent_id in asset_mapping:
asset_mapping[old_id].parent_asset_id = asset_mapping[old_parent_id].id

db.session.commit()
Comment thread
joshuaunity marked this conversation as resolved.
return new_assets
except Exception as e:
db.session.rollback()
raise e
28 changes: 27 additions & 1 deletion flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,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,
fetch_and_copy_all_assets_in_account,
)
from flexmeasures.api.common.responses import (
unprocessable_entity,
request_processed,
Expand Down Expand Up @@ -1542,3 +1545,26 @@ 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(
{
"account_id": fields.Int(required=True),
"target_account_id": fields.Int(required=True),
Comment thread
Flix6x marked this conversation as resolved.
Outdated
},
location="query",
)
Comment thread
Flix6x marked this conversation as resolved.
@as_json
Comment thread
joshuaunity marked this conversation as resolved.
def copy_assets(self, account_id: int, target_account_id: int):
"""
.. :quickref: Assets; Copy all assets from one account to another.
"""

# Ensure the user has administrative privileges or access to both accounts, if needed,
# but for now we just call the function as requested.
new_assets = fetch_and_copy_all_assets_in_account(account_id, target_account_id)

return {
"message": f"Successfully copied {len(new_assets)} assets to account {target_account_id}.",
"assets": [a.id for a in new_assets],
}, 200
43 changes: 43 additions & 0 deletions flexmeasures/api/v3_0/tests/test_assets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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 fetch_and_copy_all_assets_in_account
from flexmeasures.utils.unit_utils import is_valid_unit


Expand Down Expand Up @@ -685,3 +686,45 @@ 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_fetch_and_copy_all_assets_in_account(setup_api_test_data, setup_accounts, db):

base_account = setup_accounts["Prosumer"]
target_account = setup_accounts["Empty"]

# Get the original assets in the base account
original_assets = db.session.scalars(
select(GenericAsset).filter_by(account_id=base_account.id)
).all()
original_asset_count = len(original_assets)

assert (
original_asset_count > 0
), "Base account should have at least one asset to test properly"

# Count assets in the target account before the operation
target_assets_before = db.session.scalars(
select(GenericAsset).filter_by(account_id=target_account.id)
).all()
target_asset_count_before = len(target_assets_before)

# Call the copy function
new_assets = fetch_and_copy_all_assets_in_account(
base_account.id, target_account.id
)

# 1. Check if the amount of assets copied are complete
target_assets_after = db.session.scalars(
select(GenericAsset).filter_by(account_id=target_account.id)
).all()

assert len(target_assets_after) == target_asset_count_before + original_asset_count
assert len(new_assets) == original_asset_count

# 2. Using the name of the original asset, search if it exists in the new account
new_asset_names = [a.name for a in target_assets_after]

for old_asset in original_assets:
expected_new_name = f"{old_asset.name} (Copy)"
assert expected_new_name in new_asset_names
10 changes: 2 additions & 8 deletions flexmeasures/ui/static/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2648,6 +2648,7 @@
]
}
},
"/api/v3_0/assets/copy": {},
Comment thread
joshuaunity marked this conversation as resolved.
Outdated
"/api/v3_0/assets/{id}": {
"delete": {
"summary": "Delete an asset.",
Expand Down Expand Up @@ -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."
Comment thread
Flix6x marked this conversation as resolved.
Outdated
},
"SensorReference": {
"type": "object",
Expand Down
Loading