diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e41bf08b1..c0f87cc216 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - - uses: isort/isort-action@master + - uses: isort/isort-action@v1.1.1 with: sort-paths: antarest, tests requirementsFiles: "requirements-dev.txt" @@ -156,7 +156,7 @@ jobs: with: name: python-code-coverage-report - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master + uses: sonarsource/sonarcloud-github-action@v3.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/antarest/__init__.py b/antarest/__init__.py index f8b3bd0418..ed494cf4c5 100644 --- a/antarest/__init__.py +++ b/antarest/__init__.py @@ -19,9 +19,9 @@ # Standard project metadata -__version__ = "2.18.0" +__version__ = "2.18.1" __author__ = "RTE, Antares Web Team" -__date__ = "2024-11-29" +__date__ = "2024-12-02" # noinspection SpellCheckingInspection __credits__ = "(c) Réseau de Transport de l’Électricité (RTE)" diff --git a/antarest/eventbus/business/redis_eventbus.py b/antarest/eventbus/business/redis_eventbus.py index f3642bf994..8bbf6cbc38 100644 --- a/antarest/eventbus/business/redis_eventbus.py +++ b/antarest/eventbus/business/redis_eventbus.py @@ -20,6 +20,7 @@ logger = logging.getLogger(__name__) REDIS_STORE_KEY = "events" +MAX_EVENTS_LIST_SIZE = 1000 class RedisEventBus(IEventBusBackend): @@ -41,14 +42,23 @@ def pull_queue(self, queue: str) -> Optional[Event]: return None def get_events(self) -> List[Event]: + messages = [] try: - event = self.pubsub.get_message(ignore_subscribe_messages=True) - if event is not None: - return [Event.parse_raw(event["data"])] + while msg := self.pubsub.get_message(ignore_subscribe_messages=True): + messages.append(msg) + if len(messages) >= MAX_EVENTS_LIST_SIZE: + break except Exception: - logger.error("Failed to retrieve or parse event !", exc_info=True) + logger.error("Failed to retrieve events !", exc_info=True) - return [] + events = [] + for msg in messages: + try: + events.append(Event.model_validate_json(msg["data"])) + except Exception: + logger.error(f"Failed to parse event ! {msg}", exc_info=True) + + return events def clear_events(self) -> None: # Nothing to do diff --git a/antarest/eventbus/service.py b/antarest/eventbus/service.py index 201efb3be6..eadf75e8c8 100644 --- a/antarest/eventbus/service.py +++ b/antarest/eventbus/service.py @@ -24,6 +24,9 @@ logger = logging.getLogger(__name__) +EVENT_LOOP_REST_TIME = 0.2 + + class EventBusService(IEventBus): def __init__(self, backend: IEventBusBackend, autostart: bool = True) -> None: self.backend = backend @@ -76,18 +79,22 @@ def remove_listener(self, listener_id: str) -> None: async def _run_loop(self) -> None: while True: - time.sleep(0.2) try: - await self._on_events() + processed_events_count = await self._on_events() + # Give the loop some rest if it has nothing to do + if processed_events_count == 0: + await asyncio.sleep(EVENT_LOOP_REST_TIME) except Exception as e: logger.error("Unexpected error when processing events", exc_info=e) - async def _on_events(self) -> None: + async def _on_events(self) -> int: + processed_events_count = 0 with self.lock: for queue in self.consumers: if len(self.consumers[queue]) > 0: event = self.backend.pull_queue(queue) while event is not None: + processed_events_count += 1 try: await list(self.consumers[queue].values())[ random.randint(0, len(self.consumers[queue]) - 1) @@ -99,7 +106,9 @@ async def _on_events(self) -> None: ) event = self.backend.pull_queue(queue) - for e in self.backend.get_events(): + events = self.backend.get_events() + processed_events_count += len(events) + for e in events: if e.type in self.listeners: responses = await asyncio.gather( *[ @@ -115,6 +124,7 @@ async def _on_events(self) -> None: exc_info=res, ) self.backend.clear_events() + return processed_events_count def _async_loop(self, new_loop: bool = True) -> None: loop = asyncio.new_event_loop() if new_loop else asyncio.get_event_loop() diff --git a/antarest/study/business/link_management.py b/antarest/study/business/link_management.py index 777ce3cd9e..7c6a971fde 100644 --- a/antarest/study/business/link_management.py +++ b/antarest/study/business/link_management.py @@ -11,34 +11,21 @@ # This file is part of the Antares project. import typing as t -from typing import Any, Dict +from typing import Any from antares.study.version import StudyVersion -from antarest.core.exceptions import ConfigFileNotFound, LinkNotFound, LinkValidationError +from antarest.core.exceptions import LinkNotFound from antarest.core.model import JSON -from antarest.study.business.all_optional_meta import all_optional_model, camel_case_model from antarest.study.business.model.link_model import LinkBaseDTO, LinkDTO, LinkInternal from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import RawStudy, Study -from antarest.study.storage.rawstudy.model.filesystem.config.links import LinkProperties from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_link import CreateLink from antarest.study.storage.variantstudy.model.command.remove_link import RemoveLink -from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig from antarest.study.storage.variantstudy.model.command.update_link import UpdateLink -_ALL_LINKS_PATH = "input/links" - - -@all_optional_model -@camel_case_model -class LinkOutput(LinkProperties): - """ - DTO object use to get the link information. - """ - class LinkManager: def __init__(self, storage_service: StudyStorageService) -> None: @@ -61,6 +48,17 @@ def get_all_links(self, study: Study) -> t.List[LinkDTO]: return result + def get_link(self, study: RawStudy, link: LinkInternal) -> LinkInternal: + file_study = self.storage_service.get_storage(study).get_raw(study) + + link_properties = self._get_link_if_exists(file_study, link) + + link_properties.update({"area1": link.area1, "area2": link.area2}) + + updated_link = LinkInternal.model_validate(link_properties) + + return updated_link + def create_link(self, study: Study, link_creation_dto: LinkDTO) -> LinkDTO: link = link_creation_dto.to_internal(StudyVersion.parse(study.version)) @@ -80,16 +78,16 @@ def create_link(self, study: Study, link_creation_dto: LinkDTO) -> LinkDTO: return link_creation_dto def update_link(self, study: RawStudy, area_from: str, area_to: str, link_update_dto: LinkBaseDTO) -> LinkDTO: - link_dto = LinkDTO(area1=area_from, area2=area_to, **link_update_dto.model_dump()) + link_dto = LinkDTO(area1=area_from, area2=area_to, **link_update_dto.model_dump(exclude_unset=True)) link = link_dto.to_internal(StudyVersion.parse(study.version)) file_study = self.storage_service.get_storage(study).get_raw(study) - self.get_link_if_exists(file_study, link) + self._get_link_if_exists(file_study, link) command = UpdateLink( - area1=area_from, - area2=area_to, + area1=link.area1, + area2=link.area2, parameters=link.model_dump( include=link_update_dto.model_fields_set, exclude={"area1", "area2"}, exclude_none=True ), @@ -99,25 +97,21 @@ def update_link(self, study: RawStudy, area_from: str, area_to: str, link_update execute_or_add_commands(study, file_study, [command], self.storage_service) - updated_link = self.get_internal_link(study, link) + updated_link = self.get_link(study, link) return updated_link.to_dto() - def get_link_if_exists(self, file_study: FileStudy, link: LinkInternal) -> dict[str, Any]: - try: - return file_study.tree.get(["input", "links", link.area1, "properties", link.area2]) - except KeyError: - raise LinkNotFound(f"The link {link.area1} -> {link.area2} is not present in the study") - - def get_internal_link(self, study: RawStudy, link: LinkInternal) -> LinkInternal: - file_study = self.storage_service.get_storage(study).get_raw(study) - - link_properties = self.get_link_if_exists(file_study, link) - - link_properties.update({"area1": link.area1, "area2": link.area2}) - updated_link = LinkInternal.model_validate(link_properties) + def update_links( + self, + study: RawStudy, + update_links_by_ids: t.Mapping[t.Tuple[str, str], LinkBaseDTO], + ) -> t.Mapping[t.Tuple[str, str], LinkBaseDTO]: + new_links_by_ids = {} + for (area1, area2), update_link_dto in update_links_by_ids.items(): + updated_link = self.update_link(study, area1, area2, update_link_dto) + new_links_by_ids[(area1, area2)] = updated_link - return updated_link + return new_links_by_ids def delete_link(self, study: RawStudy, area1_id: str, area2_id: str) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) @@ -129,69 +123,12 @@ def delete_link(self, study: RawStudy, area1_id: str, area2_id: str) -> None: ) execute_or_add_commands(study, file_study, [command], self.storage_service) - def get_all_links_props(self, study: RawStudy) -> t.Mapping[t.Tuple[str, str], LinkOutput]: - """ - Retrieves all links properties from the study. - - Args: - study: The raw study object. - Returns: - A mapping of link IDS `(area1_id, area2_id)` to link properties. - Raises: - ConfigFileNotFound: if a configuration file is not found. - """ - file_study = self.storage_service.get_storage(study).get_raw(study) - - # Get the link information from the `input/links/{area1}/properties.ini` file. - path = _ALL_LINKS_PATH + def _get_link_if_exists(self, file_study: FileStudy, link: LinkInternal) -> dict[str, Any]: try: - links_cfg = file_study.tree.get(path.split("/"), depth=5) + return file_study.tree.get(["input", "links", link.area1, "properties", link.area2]) except KeyError: - raise ConfigFileNotFound(path) from None - - # areas_cfg contains a dictionary where the keys are the area IDs, - # and the values are objects that can be converted to `LinkFolder`. - links_by_ids = {} - for area1_id, entries in links_cfg.items(): - property_map = entries.get("properties") or {} - for area2_id, properties_cfg in property_map.items(): - area1_id, area2_id = sorted([area1_id, area2_id]) - properties = LinkProperties(**properties_cfg) - links_by_ids[(area1_id, area2_id)] = LinkOutput(**properties.model_dump(mode="json", by_alias=False)) - - return links_by_ids - - def update_links_props( - self, - study: RawStudy, - update_links_by_ids: t.Mapping[t.Tuple[str, str], LinkOutput], - ) -> t.Mapping[t.Tuple[str, str], LinkOutput]: - old_links_by_ids = self.get_all_links_props(study) - new_links_by_ids = {} - file_study = self.storage_service.get_storage(study).get_raw(study) - commands = [] - for (area1, area2), update_link_dto in update_links_by_ids.items(): - # Update the link properties. - old_link_dto = old_links_by_ids[(area1, area2)] - new_link_dto = old_link_dto.copy( - update=update_link_dto.model_dump(mode="json", by_alias=False, exclude_none=True) - ) - new_links_by_ids[(area1, area2)] = new_link_dto - - # Convert the DTO to a configuration object and update the configuration file. - properties = LinkProperties(**new_link_dto.model_dump(by_alias=False)) - path = f"{_ALL_LINKS_PATH}/{area1}/properties/{area2}" - cmd = UpdateConfig( - target=path, - data=properties.to_config(), - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ) - commands.append(cmd) - - execute_or_add_commands(study, file_study, commands, self.storage_service) - return new_links_by_ids + raise LinkNotFound(f"The link {link.area1} -> {link.area2} is not present in the study") @staticmethod def get_table_schema() -> JSON: - return LinkOutput.schema() + return LinkBaseDTO.model_json_schema() diff --git a/antarest/study/business/model/link_model.py b/antarest/study/business/model/link_model.py index 977ce69ee3..07398dc88d 100644 --- a/antarest/study/business/model/link_model.py +++ b/antarest/study/business/model/link_model.py @@ -12,19 +12,122 @@ import typing as t from antares.study.version import StudyVersion -from pydantic import ConfigDict, Field, model_validator +from pydantic import BeforeValidator, ConfigDict, Field, PlainSerializer, model_validator from antarest.core.exceptions import LinkValidationError from antarest.core.serialization import AntaresBaseModel from antarest.core.utils.string import to_camel_case, to_kebab_case +from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.model import STUDY_VERSION_8_2 -from antarest.study.storage.rawstudy.model.filesystem.config.links import ( - AssetType, - FilterOption, - LinkStyle, - TransmissionCapacity, - comma_separated_enum_list, -) + + +class AssetType(EnumIgnoreCase): + """ + Enum representing the type of asset for a link between two areas. + + Attributes: + AC: Represents an Alternating Current link. This is the most common type of electricity transmission. + DC: Represents a Direct Current link. This is typically used for long-distance transmission. + GAZ: Represents a gas link. This is used when the link is related to gas transmission. + VIRT: Represents a virtual link. This is used when the link doesn't physically exist + but is used for modeling purposes. + OTHER: Represents any other type of link that doesn't fall into the above categories. + """ + + AC = "ac" + DC = "dc" + GAZ = "gaz" + VIRT = "virt" + OTHER = "other" + + +class TransmissionCapacity(EnumIgnoreCase): + """ + Enum representing the transmission capacity of a link. + + Attributes: + INFINITE: Represents a link with infinite transmission capacity. + This means there are no limits on the amount of electricity that can be transmitted. + IGNORE: Represents a link where the transmission capacity is ignored. + This means the capacity is not considered during simulations. + ENABLED: Represents a link with a specific transmission capacity. + This means the capacity is considered in the model and has a certain limit. + """ + + INFINITE = "infinite" + IGNORE = "ignore" + ENABLED = "enabled" + + +class LinkStyle(EnumIgnoreCase): + """ + Enum representing the style of a link in a network visualization. + + Attributes: + DOT: Represents a dotted line style. + PLAIN: Represents a solid line style. + DASH: Represents a dashed line style. + DOT_DASH: Represents a line style with alternating dots and dashes. + """ + + DOT = "dot" + PLAIN = "plain" + DASH = "dash" + DOT_DASH = "dotdash" + OTHER = "other" + + +class FilterOption(EnumIgnoreCase): + """ + Enum representing the time filter options for data visualization or analysis in Antares Web. + + Attributes: + HOURLY: Represents filtering data by the hour. + DAILY: Represents filtering data by the day. + WEEKLY: Represents filtering data by the week. + MONTHLY: Represents filtering data by the month. + ANNUAL: Represents filtering data by the year. + """ + + HOURLY = "hourly" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + ANNUAL = "annual" + + +def validate_filters( + filter_value: t.Union[t.List[FilterOption], str], enum_cls: t.Type[FilterOption] +) -> t.List[FilterOption]: + if isinstance(filter_value, str): + if not filter_value.strip(): + return [] + + filter_accepted_values = [e for e in enum_cls] + + options = filter_value.replace(" ", "").split(",") + + invalid_options = [opt for opt in options if opt not in filter_accepted_values] + if invalid_options: + raise LinkValidationError( + f"Invalid value(s) in filters: {', '.join(invalid_options)}. " + f"Allowed values are: {', '.join(filter_accepted_values)}." + ) + options_enum: t.List[FilterOption] = list(dict.fromkeys(enum_cls(opt) for opt in options)) + return options_enum + + return filter_value + + +def join_with_comma(values: t.List[FilterOption]) -> str: + return ", ".join(value.name.lower() for value in values) + + +comma_separated_enum_list = t.Annotated[ + t.List[FilterOption], + BeforeValidator(lambda x: validate_filters(x, FilterOption)), + PlainSerializer(lambda x: join_with_comma(x)), +] DEFAULT_COLOR = 112 FILTER_VALUES: t.List[FilterOption] = [ diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index a60cdea1c3..e7a12b0289 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -25,7 +25,8 @@ from antarest.study.business.areas.thermal_management import ThermalClusterInput, ThermalManager from antarest.study.business.binding_constraint_management import BindingConstraintManager, ConstraintInput from antarest.study.business.enum_ignore_case import EnumIgnoreCase -from antarest.study.business.link_management import LinkManager, LinkOutput +from antarest.study.business.link_management import LinkManager +from antarest.study.business.model.link_model import LinkBaseDTO from antarest.study.model import STUDY_VERSION_8_2, RawStudy _TableIndex = str # row name @@ -98,15 +99,15 @@ def _get_table_data_unsafe(self, study: RawStudy, table_type: TableModeType) -> areas_map = self._area_manager.get_all_area_props(study) data = {area_id: area.model_dump(mode="json", by_alias=True) for area_id, area in areas_map.items()} elif table_type == TableModeType.LINK: - links_map = self._link_manager.get_all_links_props(study) + links_map = self._link_manager.get_all_links(study) excludes = ( set() if StudyVersion.parse(study.version) >= STUDY_VERSION_8_2 else {"filter_synthesis", "filter_year_by_year"} ) data = { - f"{area1_id} / {area2_id}": link.model_dump(mode="json", by_alias=True, exclude=excludes) - for (area1_id, area2_id), link in links_map.items() + f"{link.area1} / {link.area2}": link.model_dump(mode="json", by_alias=True, exclude=excludes) + for link in links_map } elif table_type == TableModeType.THERMAL: thermals_by_areas = self._thermal_manager.get_all_thermals_props(study) @@ -199,8 +200,8 @@ def update_table_data( data = {area_id: area.model_dump(by_alias=True, exclude_none=True) for area_id, area in areas_map.items()} return data elif table_type == TableModeType.LINK: - links_map = {tuple(key.split(" / ")): LinkOutput(**values) for key, values in data.items()} - updated_map = self._link_manager.update_links_props(study, links_map) # type: ignore + links_map = {tuple(key.split(" / ")): LinkBaseDTO(**values) for key, values in data.items()} + updated_map = self._link_manager.update_links(study, links_map) # type: ignore excludes = ( set() if StudyVersion.parse(study.version) >= STUDY_VERSION_8_2 diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/links.py b/antarest/study/storage/rawstudy/model/filesystem/config/links.py deleted file mode 100644 index 9bfe12cd11..0000000000 --- a/antarest/study/storage/rawstudy/model/filesystem/config/links.py +++ /dev/null @@ -1,231 +0,0 @@ -# Copyright (c) 2024, RTE (https://www.rte-france.com) -# -# See AUTHORS.txt -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# SPDX-License-Identifier: MPL-2.0 -# -# This file is part of the Antares project. - -""" -Object model used to read and update link configuration. -""" - -import typing as t - -from pydantic import BeforeValidator, Field, PlainSerializer, field_validator, model_validator - -from antarest.core.exceptions import LinkValidationError -from antarest.study.business.enum_ignore_case import EnumIgnoreCase -from antarest.study.storage.rawstudy.model.filesystem.config.field_validators import ( - validate_color_rgb, - validate_colors, - validate_filtering, -) -from antarest.study.storage.rawstudy.model.filesystem.config.ini_properties import IniProperties - - -# noinspection SpellCheckingInspection -class AssetType(EnumIgnoreCase): - """ - Enum representing the type of asset for a link between two areas. - - Attributes: - AC: Represents an Alternating Current link. This is the most common type of electricity transmission. - DC: Represents a Direct Current link. This is typically used for long-distance transmission. - GAZ: Represents a gas link. This is used when the link is related to gas transmission. - VIRT: Represents a virtual link. This is used when the link doesn't physically exist - but is used for modeling purposes. - OTHER: Represents any other type of link that doesn't fall into the above categories. - """ - - AC = "ac" - DC = "dc" - GAZ = "gaz" - VIRT = "virt" - OTHER = "other" - - -class TransmissionCapacity(EnumIgnoreCase): - """ - Enum representing the transmission capacity of a link. - - Attributes: - INFINITE: Represents a link with infinite transmission capacity. - This means there are no limits on the amount of electricity that can be transmitted. - IGNORE: Represents a link where the transmission capacity is ignored. - This means the capacity is not considered during simulations. - ENABLED: Represents a link with a specific transmission capacity. - This means the capacity is considered in the model and has a certain limit. - """ - - INFINITE = "infinite" - IGNORE = "ignore" - ENABLED = "enabled" - - -class LinkStyle(EnumIgnoreCase): - """ - Enum representing the style of a link in a network visualization. - - Attributes: - DOT: Represents a dotted line style. - PLAIN: Represents a solid line style. - DASH: Represents a dashed line style. - DOT_DASH: Represents a line style with alternating dots and dashes. - """ - - DOT = "dot" - PLAIN = "plain" - DASH = "dash" - DOT_DASH = "dotdash" - OTHER = "other" - - -class FilterOption(EnumIgnoreCase): - """ - Enum representing the time filter options for data visualization or analysis in Antares Web. - - Attributes: - HOURLY: Represents filtering data by the hour. - DAILY: Represents filtering data by the day. - WEEKLY: Represents filtering data by the week. - MONTHLY: Represents filtering data by the month. - ANNUAL: Represents filtering data by the year. - """ - - HOURLY = "hourly" - DAILY = "daily" - WEEKLY = "weekly" - MONTHLY = "monthly" - ANNUAL = "annual" - - -def validate_filters( - filter_value: t.Union[t.List[FilterOption], str], enum_cls: t.Type[FilterOption] -) -> t.List[FilterOption]: - if isinstance(filter_value, str): - if not filter_value.strip(): - return [] - - filter_accepted_values = [e for e in enum_cls] - - options = filter_value.replace(" ", "").split(",") - - invalid_options = [opt for opt in options if opt not in filter_accepted_values] - if invalid_options: - raise LinkValidationError( - f"Invalid value(s) in filters: {', '.join(invalid_options)}. " - f"Allowed values are: {', '.join(filter_accepted_values)}." - ) - - return [enum_cls(opt) for opt in options] - - return filter_value - - -def join_with_comma(values: t.List[FilterOption]) -> str: - return ", ".join(value.name.lower() for value in values) - - -comma_separated_enum_list = t.Annotated[ - t.List[FilterOption], - BeforeValidator(lambda x: validate_filters(x, FilterOption)), - PlainSerializer(lambda x: join_with_comma(x)), -] - - -class LinkProperties(IniProperties): - """ - Configuration read from a section in the `input/links//properties.ini` file. - - Usage: - - >>> from antarest.study.storage.rawstudy.model.filesystem.config.links import LinkProperties - >>> from pprint import pprint - - Create and validate a new `LinkProperties` object from a dictionary read from a configuration file. - - >>> obj = { - ... "hurdles-cost": "false", - ... "loop-flow": "false", - ... "use-phase-shifter": "false", - ... "transmission-capacities": "infinite", - ... "asset-type": "ac", - ... "link-style": "plain", - ... "link-width": "1", - ... "colorr": "80", - ... "colorg": "192", - ... "colorb": "255", - ... "comments": "This is a link", - ... "display-comments": "true", - ... "filter-synthesis": "hourly, daily, weekly, monthly, annual", - ... "filter-year-by-year": "hourly, daily, weekly, monthly, annual", - ... } - - >>> opt = LinkProperties(**obj) - - >>> pprint(opt.model_dump(by_alias=True), width=80) - {'asset-type': , - 'colorRgb': '#50C0FF', - 'comments': 'This is a link', - 'display-comments': True, - 'filter-synthesis': 'hourly, daily, weekly, monthly, annual', - 'filter-year-by-year': 'hourly, daily, weekly, monthly, annual', - 'hurdles-cost': False, - 'link-style': 'plain', - 'link-width': 1, - 'loop-flow': False, - 'transmission-capacities': , - 'use-phase-shifter': False} - - >>> pprint(opt.to_config(), width=80) - {'asset-type': 'ac', - 'colorb': 255, - 'colorg': 192, - 'colorr': 80, - 'comments': 'This is a link', - 'display-comments': True, - 'filter-synthesis': 'hourly, daily, weekly, monthly, annual', - 'filter-year-by-year': 'hourly, daily, weekly, monthly, annual', - 'hurdles-cost': False, - 'link-style': 'plain', - 'link-width': 1, - 'loop-flow': False, - 'transmission-capacities': 'infinite', - 'use-phase-shifter': False} - """ - - hurdles_cost: bool = Field(default=False, alias="hurdles-cost") - loop_flow: bool = Field(default=False, alias="loop-flow") - use_phase_shifter: bool = Field(default=False, alias="use-phase-shifter") - transmission_capacities: TransmissionCapacity = Field( - default=TransmissionCapacity.ENABLED, alias="transmission-capacities" - ) - asset_type: AssetType = Field(default=AssetType.AC, alias="asset-type") - link_style: str = Field(default="plain", alias="link-style") - link_width: int = Field(default=1, alias="link-width") - comments: str = Field(default="", alias="comments") # unknown field?! - display_comments: bool = Field(default=True, alias="display-comments") - filter_synthesis: str = Field(default="", alias="filter-synthesis") - filter_year_by_year: str = Field(default="", alias="filter-year-by-year") - color_rgb: str = Field( - "#707070", - alias="colorRgb", - description="color of the area in the map", - ) - - @field_validator("filter_synthesis", "filter_year_by_year", mode="before") - def _validate_filtering(cls, v: t.Any) -> str: - return validate_filtering(v) - - @field_validator("color_rgb", mode="before") - def _validate_color_rgb(cls, v: t.Any) -> str: - return validate_color_rgb(v) - - @model_validator(mode="before") - def _validate_colors(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: - return validate_colors(values) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 00c33a8f5a..2a35203f73 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,8 +1,22 @@ Antares Web Changelog ===================== +v2.18.1 (2024-12-02) +-------------------- + +## What's Changed + +### Bug Fixes + +* **ui-tablemode**: style missing [`2257`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2257) +* **ui-studies**: multiple API calls on study list view [`2258`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2258) +* **events**: avoid slow processing of events [`2259`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2259) + +**Full Changelog**: https://github.com/AntaresSimulatorTeam/AntaREST/compare/v2.18.0...v2.18.1 + + v2.18.0 (2024-11-29) -------------------- +-------------------- ## What's Changed diff --git a/pyproject.toml b/pyproject.toml index 5e77bb439e..00613c0cc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] [project] name = "AntaREST" -version = "2.18.0" +version = "2.18.1" authors = [{name="RTE, Antares Web Team", email="andrea.sgattoni@rte-france.com" }] description="Antares Server" readme = {file = "README.md", content-type = "text/markdown"} diff --git a/resources/application.yaml b/resources/application.yaml index c962d09015..21ec482177 100644 --- a/resources/application.yaml +++ b/resources/application.yaml @@ -52,3 +52,9 @@ logging: # True to get sqlalchemy logs debug: False + +# Uncomment these lines to use redis as a backend for the eventbus +# It is required to use redis when using this application on multiple workers in a preforked model like gunicorn for instance +#redis: +# host: localhost +# port: 6379 diff --git a/sonar-project.properties b/sonar-project.properties index c0897c1d14..92b73ee890 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,5 +6,5 @@ sonar.exclusions=antarest/gui.py,antarest/main.py sonar.python.coverage.reportPaths=coverage.xml sonar.python.version=3.11 sonar.javascript.lcov.reportPaths=webapp/coverage/lcov.info -sonar.projectVersion=2.18.0 +sonar.projectVersion=2.18.1 sonar.coverage.exclusions=antarest/gui.py,antarest/main.py,antarest/singleton_services.py,antarest/worker/archive_worker_service.py,webapp/**/*,,antarest/fastapi_jwt_auth/** \ No newline at end of file diff --git a/tests/eventbus/test_redis_event_bus.py b/tests/eventbus/test_redis_event_bus.py index 37f69e1fad..fa8722f742 100644 --- a/tests/eventbus/test_redis_event_bus.py +++ b/tests/eventbus/test_redis_event_bus.py @@ -29,8 +29,9 @@ def test_lifecycle(): payload="foo", permissions=PermissionInfo(public_mode=PublicMode.READ), ) + serialized = event.model_dump_json() - pubsub_mock.get_message.return_value = {"data": serialized} + pubsub_mock.get_message = Mock(side_effect=[{"data": serialized}, {"data": serialized}, None]) eventbus.push_event(event) redis_client.publish.assert_called_once_with("events", serialized) - assert eventbus.get_events() == [event] + assert eventbus.get_events() == [event, event] diff --git a/tests/integration/study_data_blueprint/test_link.py b/tests/integration/study_data_blueprint/test_link.py index 635aedb830..8cf04d58f8 100644 --- a/tests/integration/study_data_blueprint/test_link.py +++ b/tests/integration/study_data_blueprint/test_link.py @@ -9,12 +9,10 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. -from sys import stderr - import pytest from starlette.testclient import TestClient -from antarest.study.storage.rawstudy.model.filesystem.config.links import TransmissionCapacity +from antarest.study.business.model.link_model import TransmissionCapacity from tests.integration.prepare_proxy import PreparerProxy @@ -70,6 +68,32 @@ def test_link_update(self, client: TestClient, user_access_token: str, study_typ } assert expected == res.json() + # Test update link area not ordered + + res = client.put( + f"/v1/studies/{study_id}/links/{area2_id}/{area1_id}", + json={"hurdlesCost": False}, + ) + assert res.status_code == 200 + expected = { + "area1": "area 1", + "area2": "area 2", + "assetType": "ac", + "colorb": 112, + "colorg": 112, + "colorr": 150, + "displayComments": True, + "filterSynthesis": "hourly, daily, weekly, monthly, annual", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + "hurdlesCost": False, + "linkStyle": "plain", + "linkWidth": 1.0, + "loopFlow": False, + "transmissionCapacities": "enabled", + "usePhaseShifter": False, + } + assert expected == res.json() + # Test update link with non existing area res = client.put( @@ -286,6 +310,34 @@ def test_link_820(self, client: TestClient, user_access_token: str, study_type: } assert expected == res.json() + # Test create link with double value in filter + + client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}") + res = client.post( + f"/v1/studies/{study_id}/links", + json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "hourly, hourly"}, + ) + + assert res.status_code == 200, res.json() + expected = { + "area1": "area 1", + "area2": "area 2", + "assetType": "ac", + "colorb": 112, + "colorg": 112, + "colorr": 112, + "displayComments": True, + "filterSynthesis": "hourly", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + "hurdlesCost": False, + "linkStyle": "plain", + "linkWidth": 1.0, + "loopFlow": False, + "transmissionCapacities": "enabled", + "usePhaseShifter": False, + } + assert expected == res.json() + def test_create_link_810(self, client: TestClient, user_access_token: str) -> None: client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index 2d162cf43d..a7a398ed52 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -14,9 +14,11 @@ import typing as t import pytest +from antares.study.version import StudyVersion from starlette.testclient import TestClient from antarest.core.tasks.model import TaskStatus +from antarest.study.model import STUDY_VERSION_8_2 from tests.integration.utils import wait_task_completion # noinspection SpellCheckingInspection @@ -188,61 +190,63 @@ def test_lifecycle__nominal( assert res.status_code == 200, res.json() actual = res.json() assert set(actual["properties"]) == { - "colorRgb", - "comments", - "hurdlesCost", - "loopFlow", - "usePhaseShifter", - "transmissionCapacities", "assetType", - "linkStyle", - "linkWidth", + "colorb", + "colorg", + "colorr", "displayComments", "filterSynthesis", "filterYearByYear", + "hurdlesCost", + "linkStyle", + "linkWidth", + "loopFlow", + "transmissionCapacities", + "usePhaseShifter", } - res = client.put( - f"/v1/studies/{internal_study_id}/table-mode/links", - json={ - "de / fr": { - "colorRgb": "#FFA500", - "displayComments": False, - "filterSynthesis": "hourly, daily, weekly, annual", - "filterYearByYear": "hourly, daily, monthly, annual", - "hurdlesCost": True, - "linkStyle": "plain", - "linkWidth": 2, - "loopFlow": False, - "transmissionCapacities": "ignore", - }, - "es / fr": { - "colorRgb": "#FF6347", - "displayComments": True, - "filterSynthesis": "hourly, daily, weekly, monthly, annual, annual", # duplicate is ignored - "filterYearByYear": "hourly, daily, weekly, annual", - "hurdlesCost": True, - "linkStyle": "plain", - "linkWidth": 1, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": True, - }, - "fr / it": { - "comments": "Link from France to Italie", - "assetType": "DC", # case-insensitive - }, + # Test links + + json_input = { + "de / fr": { + "assetType": "ac", + "colorb": 100, + "colorg": 150, + "colorr": 200, + "displayComments": False, + "hurdlesCost": True, + "linkStyle": "plain", + "linkWidth": 2, + "loopFlow": False, + "transmissionCapacities": "ignore", }, - ) - assert res.status_code == 200, res.json() + "es / fr": { + "assetType": "ac", + "colorb": 100, + "colorg": 150, + "colorr": 200, + "displayComments": True, + "hurdlesCost": True, + "linkStyle": "plain", + "linkWidth": 1, + "loopFlow": False, + "transmissionCapacities": "enabled", + "usePhaseShifter": True, + }, + "fr / it": { + "assetType": "dc", # case-insensitive + }, + } + expected_links = { "de / fr": { + "area1": "de", + "area2": "fr", "assetType": "ac", - "colorRgb": "#FFA500", - "comments": "", + "colorb": 100, + "colorg": 150, + "colorr": 200, "displayComments": False, - "filterSynthesis": "hourly, daily, weekly, annual", - "filterYearByYear": "hourly, daily, monthly, annual", "hurdlesCost": True, "linkStyle": "plain", "linkWidth": 2, @@ -251,12 +255,13 @@ def test_lifecycle__nominal( "usePhaseShifter": False, }, "de / it": { + "area1": "de", + "area2": "it", "assetType": "ac", - "colorRgb": "#707070", - "comments": "", + "colorr": 112, + "colorg": 112, + "colorb": 112, "displayComments": True, - "filterSynthesis": "hourly, daily, weekly, monthly, annual", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", "hurdlesCost": False, "linkStyle": "plain", "linkWidth": 1, @@ -265,12 +270,13 @@ def test_lifecycle__nominal( "usePhaseShifter": False, }, "es / fr": { + "area1": "es", + "area2": "fr", "assetType": "ac", - "colorRgb": "#FF6347", - "comments": "", + "colorb": 100, + "colorg": 150, + "colorr": 200, "displayComments": True, - "filterSynthesis": "hourly, daily, weekly, monthly, annual", - "filterYearByYear": "hourly, daily, weekly, annual", "hurdlesCost": True, "linkStyle": "plain", "linkWidth": 1, @@ -279,12 +285,13 @@ def test_lifecycle__nominal( "usePhaseShifter": True, }, "fr / it": { + "area1": "fr", + "area2": "it", "assetType": "dc", - "colorRgb": "#707070", - "comments": "Link from France to Italie", + "colorb": 112, + "colorg": 112, + "colorr": 112, "displayComments": True, - "filterSynthesis": "", - "filterYearByYear": "hourly", "hurdlesCost": True, "linkStyle": "plain", "linkWidth": 1, @@ -293,23 +300,39 @@ def test_lifecycle__nominal( "usePhaseShifter": False, }, } - # removes filter fields for study version prior to v8.2 - if study_version < 820: - for key in expected_links: - del expected_links[key]["filterSynthesis"] - del expected_links[key]["filterYearByYear"] - # asserts actual equals expected without the non-updated link. + # Add filters for versions > 8.2 + if StudyVersion.parse(study_version) > STUDY_VERSION_8_2: + for link in json_input.values(): + link.update( + { + "filterSynthesis": "hourly, daily, weekly, monthly, annual", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + } + ) + for link in expected_links.values(): + link.update( + { + "filterSynthesis": "hourly, daily, weekly, monthly, annual", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + } + ) + + res = client.put(f"/v1/studies/{internal_study_id}/table-mode/links", json=json_input) + assert res.status_code == 200, res.json() + actual = res.json() - expected_result = copy.deepcopy(expected_links) - del expected_result["de / it"] - assert actual == expected_result + expected_partial = copy.deepcopy(expected_links) + del expected_partial["de / it"] + assert actual == expected_partial res = client.get(f"/v1/studies/{internal_study_id}/table-mode/links") assert res.status_code == 200, res.json() - actual = res.json() - # asserts the `de / it` link is not removed. - assert actual == expected_links + assert res.json() == expected_links + + # GET request to make sure that the GET /links works + res = client.get(f"/v1/studies/{internal_study_id}/links") + assert res.status_code == 200, res.json() # Table Mode - Thermal Clusters # ============================= diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index adae212c4f..b61074b8b7 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -25,11 +25,11 @@ from antarest.matrixstore.service import SimpleMatrixService from antarest.study.business.area_management import AreaCreationDTO, AreaManager, AreaType, UpdateAreaUi from antarest.study.business.link_management import LinkDTO, LinkManager +from antarest.study.business.model.link_model import AssetType, LinkStyle, TransmissionCapacity from antarest.study.model import Patch, PatchArea, PatchCluster, RawStudy, StudyAdditionalData from antarest.study.repository import StudyMetadataRepository from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.files import build -from antarest.study.storage.rawstudy.model.filesystem.config.links import AssetType, LinkStyle, TransmissionCapacity from antarest.study.storage.rawstudy.model.filesystem.config.model import Area, DistrictSet, FileStudyTreeConfig, Link from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ThermalConfig from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 972dba194a..c358ade70c 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -1,12 +1,12 @@ { "name": "antares-web", - "version": "2.18.0", + "version": "2.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antares-web", - "version": "2.18.0", + "version": "2.18.1", "dependencies": { "@emotion/react": "11.13.3", "@emotion/styled": "11.13.0", diff --git a/webapp/package.json b/webapp/package.json index f4702d8067..5fa9b78964 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.18.0", + "version": "2.18.1", "private": true, "type": "module", "scripts": { diff --git a/webapp/src/components/App/Studies/StudyCard/index.tsx b/webapp/src/components/App/Studies/StudyCard/index.tsx index cb74b58819..89813e6271 100644 --- a/webapp/src/components/App/Studies/StudyCard/index.tsx +++ b/webapp/src/components/App/Studies/StudyCard/index.tsx @@ -405,30 +405,27 @@ const StudyCard = memo((props: Props) => { setOpenDialog={setOpenDialog} /> - - - {t("studies.question.delete")} - - - + {/* Keep conditional rendering for dialogs and not use only `open` property, because API calls are made on mount */} + {openDialog === "properties" && ( + + )} + {openDialog === "delete" && ( + + {t("studies.question.delete")} + + )} + {openDialog === "export" && ( + + )} + {openDialog === "move" && ( + + )} ); }, areEqual); diff --git a/webapp/src/components/common/Handsontable.tsx b/webapp/src/components/common/Handsontable.tsx index 0e6d6c3f8a..89f3ef7c0f 100644 --- a/webapp/src/components/common/Handsontable.tsx +++ b/webapp/src/components/common/Handsontable.tsx @@ -18,6 +18,7 @@ import { styled } from "@mui/material"; import { forwardRef } from "react"; import * as RA from "ramda-adjunct"; import { SECONDARY_MAIN_COLOR } from "../../theme"; +import "handsontable/dist/handsontable.min.css"; // Register Handsontable's modules registerAllModules();