diff --git a/antarest/study/service.py b/antarest/study/service.py index 46a1337ca2..abee5392bd 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -2544,12 +2544,13 @@ def get_matrix_with_index_and_header( return df_matrix - def assert_no_cluster_referenced_in_bcs(self, study: Study, cluster_ids: t.Sequence[str]) -> None: + def assert_no_cluster_referenced_in_bcs(self, study: Study, area_id: str, cluster_ids: t.Sequence[str]) -> None: """ Check that no cluster is referenced in a binding constraint otherwise raise an ClusterDeletionNotAllowed Exception Args: study: input study + area_id: area ID to be checked cluster_ids: cluster IDs to be checked Returns: @@ -2558,7 +2559,7 @@ def assert_no_cluster_referenced_in_bcs(self, study: Study, cluster_ids: t.Seque for cluster_id in cluster_ids: referencing_binding_constraints = self.binding_constraint_manager.get_binding_constraints( - study, ConstraintFilters(cluster_id=cluster_id) + study, ConstraintFilters(cluster_id=f"{area_id}.{cluster_id}") )[:MAX_BINDING_CONSTRAINTS_TO_DISPLAY] if referencing_binding_constraints: first_bcs_ids = "" diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 08e3d93711..ab785c3a16 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -2185,7 +2185,7 @@ def delete_thermal_clusters( ) request_params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, request_params) - study_service.assert_no_cluster_referenced_in_bcs(study, cluster_ids) + study_service.assert_no_cluster_referenced_in_bcs(study, area_id, cluster_ids) study_service.thermal_manager.delete_clusters(study, area_id, cluster_ids) @bp.get( diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index 11ebf7289c..b093bc3e28 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -40,7 +40,6 @@ from antarest.core.utils.string import to_camel_case from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ThermalProperties -from antarest.study.storage.variantstudy.model.command.common import CommandName from tests.integration.utils import wait_task_completion DEFAULT_PROPERTIES = json.loads(ThermalProperties(name="Dummy").json()) @@ -647,7 +646,29 @@ def test_lifecycle( ) assert res.status_code in {200, 201}, res.json() - # To delete a thermal cluster, we need to provide its ID. + # verify that we can't delete the thermal cluster because it is referenced in a binding constraint + res = client.request( + "DELETE", + f"/v1/studies/{study_id}/areas/{area_id}/clusters/thermal", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[fr_gas_conventional_id], + ) + assert res.status_code == 403, res.json() + assert res.json() == { + "description": "Cluster FR_Gas conventional is not allowed to be deleted\n" + "Cluster is referenced in the following binding constraints:" + "\n1- binding constraint\n", + "exception": "ClusterDeletionNotAllowed", + } + + # delete the binding constraint + res = client.delete( + f"/v1/studies/{study_id}/bindingconstraints/{bc_obj['name']}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # Now we can delete the thermal cluster res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/clusters/thermal", @@ -655,9 +676,8 @@ def test_lifecycle( json=[fr_gas_conventional_id], ) assert res.status_code == 204, res.json() - assert res.text in {"", "null"} # Old FastAPI versions return 'null'. - # When we delete a thermal cluster, we should also delete the binding constraints that reference it. + # check that the binding constraint has been deleted # noinspection SpellCheckingInspection res = client.get( f"/v1/studies/{study_id}/bindingconstraints", @@ -1004,25 +1024,6 @@ def test_variant_lifecycle(self, client: TestClient, user_access_token: str, var assert res.status_code == 200 assert res.json()["data"] == matrix - # # create a binding constraint for - # res = client.post( - # f"/v1/studies/{variant_id}/commands", - # headers={"Authorization": f"Bearer {user_access_token}"}, - # json=[ - # { - # "action": CommandName.CREATE_BINDING_CONSTRAINT.value, - # "args": { - # "name": "binding constraint 1", - # "enabled": True, - # "time_step": "hourly", - # "operator": "less", - # "coeffs": {f"{area_id}.{cluster_id}": [2.0, 4]}, - # }, - # } - # ], - # ) - # res.raise_for_status() - # Delete the thermal cluster res = client.delete( f"/v1/studies/{variant_id}/areas/{area_id}/clusters/thermal", @@ -1049,3 +1050,179 @@ def test_variant_lifecycle(self, client: TestClient, user_access_token: str, var "replace_matrix", "remove_cluster", ] + + def test_thermal_cluster_deletion(self, client: TestClient, user_access_token: str) -> None: + """ + Test that creating a thermal cluster with invalid properties raises a validation error. + """ + + client.headers = {"Authorization": f"Bearer {user_access_token}"} + + # Create a new study + res = client.post( + f"/v1/studies", + params={"name": "My Study"}, + ) + assert res.status_code in {200, 201}, res.json() + study_id = res.json() + + # Create an area "area_1" in the study + res = client.post( + f"/v1/studies/{study_id}/areas", + json={ + "name": "area_1", + "type": "AREA", + "metadata": {"country": "FR"}, + }, + ) + assert res.status_code == 200, res.json() + + # Create an area "area_2" in the study + res = client.post( + f"/v1/studies/{study_id}/areas", + json={ + "name": "area_2", + "type": "AREA", + "metadata": {"country": "DE"}, + }, + ) + assert res.status_code == 200, res.json() + + # Create an area "area_3" in the study + res = client.post( + f"/v1/studies/{study_id}/areas", + json={ + "name": "area_3", + "type": "AREA", + "metadata": {"country": "ES"}, + }, + ) + assert res.status_code == 200, res.json() + + # Create a thermal cluster in the study for area_1 + res = client.post( + f"/v1/studies/{study_id}/areas/area_1/clusters/thermal", + json={ + "name": "cluster_1", + "group": "Nuclear", + "unitCount": 13, + "nominalCapacity": 42500, + "marginalCost": 0.1, + }, + ) + assert res.status_code == 200, res.json() + + # Create a thermal cluster in the study for area_2 + res = client.post( + f"/v1/studies/{study_id}/areas/area_2/clusters/thermal", + json={ + "name": "cluster_2", + "group": "Nuclear", + "unitCount": 13, + "nominalCapacity": 42500, + "marginalCost": 0.1, + }, + ) + assert res.status_code == 200, res.json() + + # Create a thermal cluster in the study for area_3 + res = client.post( + f"/v1/studies/{study_id}/areas/area_3/clusters/thermal", + json={ + "name": "cluster_3", + "group": "Nuclear", + "unitCount": 13, + "nominalCapacity": 42500, + "marginalCost": 0.1, + }, + ) + assert res.status_code == 200, res.json() + + # add a binding constraint that references the thermal cluster in area_1 + bc_obj = { + "name": "bc_1", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "terms": [ + { + "id": "area_1.cluster_1", + "weight": 2, + "offset": 5, + "data": {"area": "area_1", "cluster": "cluster_1"}, + } + ], + } + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json=bc_obj, + ) + assert res.status_code == 200, res.json() + + # add a binding constraint that references the thermal cluster in area_2 + bc_obj = { + "name": "bc_2", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "terms": [ + { + "id": "area_2.cluster_2", + "weight": 2, + "offset": 5, + "data": {"area": "area_2", "cluster": "cluster_2"}, + } + ], + } + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json=bc_obj, + ) + assert res.status_code == 200, res.json() + + # check that deleting the thermal cluster in area_1 fails + res = client.delete( + f"/v1/studies/{study_id}/areas/area_1/clusters/thermal", + json=["cluster_1"], + ) + assert res.status_code == 403, res.json() + + # now delete the binding constraint that references the thermal cluster in area_1 + res = client.delete( + f"/v1/studies/{study_id}/bindingconstraints/bc_1", + ) + assert res.status_code == 200, res.json() + + # check that deleting the thermal cluster in area_1 succeeds + res = client.delete( + f"/v1/studies/{study_id}/areas/area_1/clusters/thermal", + json=["cluster_1"], + ) + assert res.status_code == 204, res.json() + + # check that deleting the thermal cluster in area_2 fails + res = client.delete( + f"/v1/studies/{study_id}/areas/area_2/clusters/thermal", + json=["cluster_2"], + ) + assert res.status_code == 403, res.json() + + # now delete the binding constraint that references the thermal cluster in area_2 + res = client.delete( + f"/v1/studies/{study_id}/bindingconstraints/bc_2", + ) + assert res.status_code == 200, res.json() + + # check that deleting the thermal cluster in area_2 succeeds + res = client.delete( + f"/v1/studies/{study_id}/areas/area_2/clusters/thermal", + json=["cluster_2"], + ) + assert res.status_code == 204, res.json() + + # check that deleting the thermal cluster in area_3 succeeds + res = client.delete( + f"/v1/studies/{study_id}/areas/area_3/clusters/thermal", + json=["cluster_3"], + ) + assert res.status_code == 204, res.json() diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 18d0750cd7..2da1c792c3 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1439,7 +1439,7 @@ def test_area_management(client: TestClient, admin_access_token: str) -> None: assert result.json()["exception"] == "AreaDeletionNotAllowed" # delete binding constraint 1 - result = client.delete(f"/v1/studies/{study_id}/bindingconstraints/binding%20constraint%201") + client.delete(f"/v1/studies/{study_id}/bindingconstraints/binding%20constraint%201") # check now that we can delete the area 1 result = client.delete(f"/v1/studies/{study_id}/areas/area%201") assert result.status_code == 200, res.json() @@ -1715,3 +1715,85 @@ def test_copy(client: TestClient, admin_access_token: str, study_id: str) -> Non res = client.get(f"/v1/studies/{copied.json()}").json() assert res["groups"] == [] assert res["public_mode"] == "READ" + + +def test_links_deletion(client: TestClient, user_access_token: str) -> None: + """ + Test the deletion of links between areas. + """ + + # set client headers to user access token + client.headers = {"Authorization": f"Bearer {user_access_token}"} + + # create a study + study_id = client.post("/v1/studies?name=test").json() + + # Create an area "area_1" in the study + res = client.post( + f"/v1/studies/{study_id}/areas", + json={ + "name": "area_1", + "type": "AREA", + "metadata": {"country": "FR"}, + }, + ) + assert res.status_code == 200, res.json() + + # Create an area "area_2" in the study + res = client.post( + f"/v1/studies/{study_id}/areas", + json={ + "name": "area_2", + "type": "AREA", + "metadata": {"country": "DE"}, + }, + ) + assert res.status_code == 200, res.json() + + # create a link between the two areas + res = client.post( + f"/v1/studies/{study_id}/links", + json={ + "area1": "area_1", + "area2": "area_2", + }, + ) + assert res.status_code == 200, res.json() + + # create a binding constraint that references the link + bc_obj = { + "name": "bc_1", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "terms": [ + { + "id": "area_1%area_2", + "weight": 2, + "data": {"area1": "area_1", "area2": "area_2"}, + } + ], + } + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json=bc_obj, + ) + assert res.status_code == 200, res.json() + + # try to delete the link before deleting the binding constraint + res = client.delete(f"/v1/studies/{study_id}/links/area_1/area_2") + assert res.status_code == 403, res.json() + assert res.json() == { + "description": "Link area_1%area_2 is not allowed to be deleted\n" + "Link is referenced in the following binding constraints:\n" + "1- bc_1\n", + "exception": "LinkDeletionNotAllowed", + } + + # delete the binding constraint + res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/bc_1") + assert res.status_code == 200, res.json() + + # delete the link + res = client.delete(f"/v1/studies/{study_id}/links/area_1/area_2") + assert res.status_code == 200, res.json()