From 14dc7c6d07a264ea1ed4e7670254db35770e4f02 Mon Sep 17 00:00:00 2001 From: Mohamed Abdel Wedoud Date: Mon, 8 Jan 2024 14:01:38 +0100 Subject: [PATCH 01/13] feat(study-search): optimize the studies search engine --- antarest/launcher/service.py | 6 +- antarest/study/repository.py | 137 +++++++++++++++++- antarest/study/service.py | 56 +++---- .../study/storage/auto_archive_service.py | 5 +- antarest/study/web/studies_blueprint.py | 118 +++++++++++++-- .../studies_blueprint/test_comments.py | 2 +- tests/storage/test_service.py | 23 ++- tests/study/test_repository.py | 6 +- 8 files changed, 283 insertions(+), 70 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 25a777ef5a..aab435c3e7 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -38,6 +38,7 @@ XpansionParametersDTO, ) from antarest.launcher.repository import JobResultRepository +from antarest.study.repository import StudyFilter from antarest.study.service import StudyService from antarest.study.storage.utils import assert_permission, extract_output_name, find_single_output_path @@ -306,7 +307,10 @@ def _filter_from_user_permission(self, job_results: List[JobResult], user: Optio allowed_job_results = [] studies_ids = [job_result.study_id for job_result in job_results] - studies = {study.id: study for study in self.study_service.repository.get_all(studies_ids=studies_ids)} + studies = { + study.id: study + for study in self.study_service.repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) + } for job_result in job_results: if job_result.study_id in studies: diff --git a/antarest/study/repository.py b/antarest/study/repository.py index ac7f730fca..26a9648b32 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -1,18 +1,96 @@ import datetime import logging import typing as t +from enum import Enum +from pydantic import BaseModel from sqlalchemy import and_, or_ # type: ignore from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import CacheConstants, ICache from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.login.model import Group, Identity from antarest.study.common.utils import get_study_information from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData logger = logging.getLogger(__name__) +def escape_like(string: str, escape_char: str = "\\") -> str: + """ + Escape the string parameter used in SQL LIKE expressions. + + :: + + from sqlalchemy_utils import escape_like + + + query = session.query(User).filter( + User.name.ilike(escape_like('John')) + ) + + + :param string: a string to escape + :param escape_char: escape character + """ + return string.replace(escape_char, escape_char * 2).replace("%", escape_char + "%").replace("_", escape_char + "_") + + +class StudyFilter(BaseModel, frozen=True): + """ + Study filter + + Attrs: + - name: optional name regex of the study to match + - managed: indicate if just managed studies should be retrieved + - archived: optional if the study is archived + - variant: optional if the study is raw study + - versions: versions to filter by + - users: users to filter by + - groups: groups to filter by + - tags: tags to filter by + - studies_ids: optional list of ids to be matched, **note that if empty the query result will be empty also** + - exists: if raw study missing + - workspace: optional workspace of the study + - folder: optional folder prefix of the study + """ + + name: str = "" + managed: t.Optional[bool] = None + archived: t.Optional[bool] = None + variant: t.Optional[bool] = None + versions: t.Sequence[str] = () + users: t.Sequence[str] = () + groups: t.Sequence[str] = () + tags: t.Sequence[str] = () + studies_ids: t.Optional[t.Sequence[str]] = None + exists: t.Optional[bool] = None + workspace: str = "" + folder: str = "" + + +class StudySortBy(str, Enum): + """ + How to sort the results of studies query results + """ + + NAME_ASC = "+name" + NAME_DESC = "-name" + DATE_ASC = "+date" + DATE_DESC = "-date" + + +class StudyPagination(BaseModel, frozen=True): + """ + Pagination of a studies query results + page_nb: offset + page_size: SQL limit + """ + + page_nb: int = 0 + page_size: int = 100 + + class StudyMetadataRepository: """ Database connector to manage Study entity @@ -101,10 +179,17 @@ def get_additional_data(self, study_id: str) -> t.Optional[StudyAdditionalData]: def get_all( self, - managed: t.Optional[bool] = None, - studies_ids: t.Optional[t.List[str]] = None, - exists: bool = True, + study_filter: StudyFilter = StudyFilter(), + sort_by: StudySortBy = StudySortBy.DATE_DESC, + pagination: StudyPagination = StudyPagination(), ) -> t.List[Study]: + """ + This function goal is to create a search engine throughout the studies with optimal + runtime. + + Args: + + """ # When we fetch a study, we also need to fetch the associated owner and groups # to check the permissions of the current user efficiently. # We also need to fetch the additional data to display the study information @@ -112,19 +197,55 @@ def get_all( entity = with_polymorphic(Study, "*") q = self.session.query(entity) - if exists: + if study_filter.exists: q = q.filter(RawStudy.missing.is_(None)) q = q.options(joinedload(entity.owner)) q = q.options(joinedload(entity.groups)) q = q.options(joinedload(entity.additional_data)) - if managed is not None: - if managed: + if study_filter.managed is not None: + if study_filter.managed: q = q.filter(or_(entity.type == "variantstudy", RawStudy.workspace == DEFAULT_WORKSPACE_NAME)) else: q = q.filter(entity.type == "rawstudy") q = q.filter(RawStudy.workspace != DEFAULT_WORKSPACE_NAME) - if studies_ids is not None: - q = q.filter(entity.id.in_(studies_ids)) + if study_filter.studies_ids is not None: + q = q.filter(entity.id.in_(study_filter.studies_ids)) + if study_filter.users: + q = q.filter(Identity.name.in_(study_filter.users)) + if study_filter.groups: + q = q.filter(Group.name.in_(study_filter.groups)) + if study_filter.archived is not None: + q = q.filter(entity.archived == study_filter.archived) + if study_filter.name: + regex = f"%{escape_like(study_filter.name)}%" + q = q.filter(entity.name.ilike(regex)) + if study_filter.folder: + regex = f"{escape_like(study_filter.folder)}%" + q = q.filter(entity.folder.ilike(regex)) + if study_filter.workspace: + regex = f"%{escape_like(study_filter.workspace)}%" + q = q.filter(RawStudy.workspace.ilike(regex)) + if study_filter.variant is not None: + if study_filter.variant: + q = q.filter(entity.type == "variantstudy") + else: + q = q.filter(entity.type == "rawstudy") + if study_filter.versions: + q = q.filter(entity.version.in_(study_filter.versions)) + + # sorting + if sort_by == StudySortBy.DATE_DESC: + q = q.order_by(entity.created_at.desc()) + elif sort_by == StudySortBy.DATE_ASC: + q = q.order_by(entity.created_at.asc()) + elif sort_by == StudySortBy.NAME_DESC: + q = q.order_by(entity.name.desc()) + elif sort_by == StudySortBy.NAME_ASC: + q = q.order_by(entity.name.asc()) + + # pagination + q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) + studies: t.List[Study] = q.all() return studies diff --git a/antarest/study/service.py b/antarest/study/service.py index 7a9ee71507..79b9d4325f 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -4,6 +4,7 @@ import json import logging import os +import typing as t from datetime import datetime, timedelta from http import HTTPStatus from pathlib import Path, PurePosixPath @@ -92,7 +93,7 @@ StudyMetadataPatchDTO, StudySimResultDTO, ) -from antarest.study.repository import StudyMetadataRepository +from antarest.study.repository import StudyFilter, StudyMetadataRepository, StudyPagination, StudySortBy from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode @@ -438,44 +439,33 @@ def edit_comments( def get_studies_information( self, - managed: bool, - name: Optional[str], - workspace: Optional[str], - folder: Optional[str], params: RequestParameters, + study_filter: StudyFilter, + sort_by: StudySortBy = StudySortBy.DATE_DESC, + pagination: StudyPagination = StudyPagination(), ) -> Dict[str, StudyMetadataDTO]: """ - Get information for all studies. + Get information for matching studies of a search query. Args: - managed: indicate if just managed studies should be retrieved - name: optional name of the study to match - folder: optional folder prefix of the study to match - workspace: optional workspace of the study to match params: request parameters + study_filter: filtering parameters + sort_by: how to sort the db query results + pagination: set offset and limit for db query Returns: List of study information - """ - logger.info("Fetching study listing") + logger.info("Retrieving matching studies") studies: Dict[str, StudyMetadataDTO] = {} - cache_key = CacheConstants.STUDY_LISTING.value - cached_studies = self.cache_service.get(cache_key) - if cached_studies: - for k in cached_studies: - studies[k] = StudyMetadataDTO.parse_obj(cached_studies[k]) - else: - if managed: - logger.info("Retrieving all managed studies") - all_studies = self.repository.get_all(managed=True) - else: - logger.info("Retrieving all studies") - all_studies = self.repository.get_all() - logger.info("Studies retrieved") - for study in all_studies: - study_metadata = self._try_get_studies_information(study) - if study_metadata is not None: - studies[study_metadata.id] = study_metadata - self.cache_service.put(cache_key, studies) + matching_studies = self.repository.get_all( + study_filter=study_filter, + sort_by=sort_by, + pagination=pagination, + ) + logger.info("Studies retrieved") + for study in matching_studies: + study_metadata = self._try_get_studies_information(study) + if study_metadata is not None: + studies[study_metadata.id] = study_metadata return { s.id: s for s in filter( @@ -485,8 +475,7 @@ def get_studies_information( StudyPermissionType.READ, raising=False, ) - and study_matcher(name, workspace, folder)(study_dto) - and (not managed or study_dto.managed), + and (not study_filter.managed or study_dto.managed), studies.values(), ) } @@ -2150,7 +2139,8 @@ def check_and_update_all_study_versions_in_database(self, params: RequestParamet if params.user and not params.user.is_site_admin(): logger.error(f"User {params.user.id} is not site admin") raise UserHasNotPermissionError() - studies = self.repository.get_all(managed=False) + studies = self.repository.get_all(study_filter=StudyFilter(managed=False)) + for study in studies: storage = self.storage_service.raw_study_service storage.check_and_update_study_version_in_database(study) diff --git a/antarest/study/storage/auto_archive_service.py b/antarest/study/storage/auto_archive_service.py index 8a15cb0f49..796ae7d0fc 100644 --- a/antarest/study/storage/auto_archive_service.py +++ b/antarest/study/storage/auto_archive_service.py @@ -10,6 +10,7 @@ from antarest.core.requests import RequestParameters from antarest.core.utils.fastapi_sqlalchemy import db from antarest.study.model import RawStudy, Study +from antarest.study.repository import StudyFilter from antarest.study.service import StudyService from antarest.study.storage.utils import is_managed from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy @@ -28,7 +29,9 @@ def __init__(self, study_service: StudyService, config: Config): def _try_archive_studies(self) -> None: old_date = datetime.datetime.utcnow() - datetime.timedelta(days=self.config.storage.auto_archive_threshold_days) with db(): - studies: List[Study] = self.study_service.repository.get_all(managed=True, exists=False) + studies: List[Study] = self.study_service.repository.get_all( + study_filter=StudyFilter(managed=True, exists=False) + ) # list of study id and boolean indicating if it's a raw study (True) or a variant (False) study_ids_to_archive = [ (study.id, isinstance(study, RawStudy)) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 1b1dc72312..63ba395f65 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -2,9 +2,9 @@ import logging from http import HTTPStatus from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence -from fastapi import APIRouter, Depends, File, HTTPException, Request +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request from markupsafe import escape from antarest.core.config import Config @@ -26,12 +26,16 @@ StudyMetadataPatchDTO, StudySimResultDTO, ) +from antarest.study.repository import StudyFilter, StudyPagination, StudySortBy from antarest.study.service import StudyService from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO logger = logging.getLogger(__name__) +SEQUENCE_SEPARATOR = "," + + def create_study_routes(study_service: StudyService, ftm: FileTransferManager, config: Config) -> APIRouter: """ Endpoint implementation for studies management @@ -53,16 +57,108 @@ def create_study_routes(study_service: StudyService, ftm: FileTransferManager, c response_model=Dict[str, StudyMetadataDTO], ) def get_studies( - managed: bool = False, - name: str = "", - folder: str = "", - workspace: str = "", current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: - logger.info("Fetching study list", extra={"user": current_user.id}) - params = RequestParameters(user=current_user) - available_studies = study_service.get_studies_information(managed, name, workspace, folder, params) - return available_studies + sort_by: str = Query( + "-date", + description="- `sort_by`: Sort studies based on their name or date." + " - `+name`: Sort by name in ascending order (case-insensitive)." + " - `-name`: Sort by name in descending order (case-insensitive)." + " - `+date`: Sort by creation date in ascending order." + " - `-date`: Sort by creation date in descending order.", + alias="sortBy", + ), + page_nb: int = Query(0, description="Page number (starting from 0).", alias="pageNb"), + page_size: int = Query(100, description="Number of studies per page.", alias="pageSize"), + name: str = Query( + "", + description="Filter studies based on their name." + "Case-insensitive search for studies whose name starts with the specified value.", + alias="name", + ), + managed: Optional[bool] = Query( + None, description="Filter studies based on their management status.", alias="managed" + ), + archived: Optional[bool] = Query( + None, description="Filter studies based on their archive status.", alias="archived" + ), + variant: Optional[bool] = Query( + None, description="Filter studies based on their variant status.", alias="variant" + ), + versions: str = Query( + "", + description="Filter studies based on their version(s)." + " Provide a comma-separated list of versions for filtering.", + alias="versions", + ), + users: str = Query( + "", + description="Filter studies based on user(s)." + " Provide a comma-separated list of group IDs for filtering.", + alias="users", + ), + groups: str = Query( + "", + description="Filter studies based on group(s)." + " Provide a comma-separated list of group IDs for filtering.", + alias="groups", + ), + tags: str = Query( + "", + description="Filter studies based on tag(s)." " Provide a comma-separated list of tags for filtering.", + alias="tags", + ), + studies_ids: str = Query( + "", + description="Filter studies based on their ID(s)." + " Provide a comma-separated list of study IDs for filtering.", + alias="studiesIds", + ), + exists: bool = Query( + None, + description="Filter studies based on their existence on disk." + " - not set: No specific filtering." + " - `True`: Filter for studies existing on disk." + " - `False`: Filter for studies not existing on disk.", + alias="exists", + ), + workspace: str = Query( + "", + description="Filter studies based on their workspace." + " Search for studies whose workspace matches the specified value.", + alias="workspace", + ), + folder: str = Query( + "", + description="Filter studies based on their folder." + " Search for studies whose folder starts with the specified value.", + alias="folder", + ), + ) -> Dict[str, StudyMetadataDTO]: + logger.info("Fetching for matching studies", extra={"user": current_user.id}) + params = RequestParameters(user=current_user) + + study_filter: StudyFilter = StudyFilter( + name=name, + managed=managed, + archived=archived, + variant=variant, + versions=versions.split(SEQUENCE_SEPARATOR) if versions else (), + users=users.split(SEQUENCE_SEPARATOR) if users else (), + groups=groups.split(SEQUENCE_SEPARATOR) if groups else (), + tags=tags.split(SEQUENCE_SEPARATOR) if tags else (), + studies_ids=studies_ids.split(SEQUENCE_SEPARATOR) if studies_ids else None, + exists=exists, + workspace=workspace, + folder=folder, + ) + + matching_studies = study_service.get_studies_information( + params=params, + study_filter=study_filter, + sort_by=StudySortBy(sort_by.lower()), + pagination=StudyPagination(page_nb=page_nb, page_size=page_size), + ) + return matching_studies @bp.get( "/studies/{uuid}/comments", diff --git a/tests/integration/studies_blueprint/test_comments.py b/tests/integration/studies_blueprint/test_comments.py index ca6d746443..b282ed8781 100644 --- a/tests/integration/studies_blueprint/test_comments.py +++ b/tests/integration/studies_blueprint/test_comments.py @@ -113,7 +113,7 @@ def test_variant_study( ) assert res.status_code == 200, res.json() duration = time.time() - start - assert 0 <= duration <= 0.1, f"Duration is {duration} seconds" + assert 0 <= duration <= 0.3, f"Duration is {duration} seconds" # Update the comments of the study res = client.put( diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index b509445052..3e93090af3 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -44,7 +44,7 @@ TimeSerie, TimeSeriesData, ) -from antarest.study.repository import StudyMetadataRepository +from antarest.study.repository import StudyMetadataRepository, StudyFilter from antarest.study.service import MAX_MISSING_STUDY_TIMEOUT, StudyService, StudyUpgraderTask, UserHasNotPermissionError from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.model import ( @@ -178,10 +178,9 @@ def test_study_listing(db_session: Session) -> None: # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - managed=False, - name=None, - workspace=None, - folder=None, + study_filter=StudyFilter( + managed=False, + ), params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) @@ -196,10 +195,9 @@ def test_study_listing(db_session: Session) -> None: # 2- the `put` method of `cache` was used once with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - managed=False, - name=None, - workspace=None, - folder=None, + study_filter=StudyFilter( + managed=False, + ), params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), ) assert len(db_recorder.sql_statements) == 0, str(db_recorder) @@ -214,10 +212,9 @@ def test_study_listing(db_session: Session) -> None: # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - managed=True, - name=None, - workspace=None, - folder=None, + study_filter=StudyFilter( + managed=False, + ), params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index be1a763602..975190339e 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -7,7 +7,7 @@ from antarest.core.interfaces.cache import ICache from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy -from antarest.study.repository import StudyMetadataRepository +from antarest.study.repository import StudyFilter, StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @@ -58,7 +58,9 @@ def test_repository_get_all( # 2- accessing studies attributes does require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(managed=managed, studies_ids=studies_ids, exists=exists) + all_studies = repository.get_all( + study_filter=StudyFilter(managed=managed, studies_ids=studies_ids, exists=exists) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] From 5ba76146f98267866f880e1c5848958b69ddb52d Mon Sep 17 00:00:00 2001 From: Mohamed Abdel Wedoud Date: Mon, 15 Jan 2024 10:17:26 +0100 Subject: [PATCH 02/13] feat(study-search): improve doc, code review --- antarest/launcher/service.py | 11 +-- antarest/study/repository.py | 69 +++++++++++-------- antarest/study/service.py | 5 +- .../study/storage/auto_archive_service.py | 4 +- .../model/filesystem/config/renewable.py | 1 + antarest/study/web/studies_blueprint.py | 5 +- tests/storage/repository/test_study.py | 7 +- tests/storage/test_service.py | 45 ++++++++---- tests/study/test_repository.py | 27 +++++--- 9 files changed, 110 insertions(+), 64 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index aab435c3e7..8fd80bd09f 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -307,10 +307,13 @@ def _filter_from_user_permission(self, job_results: List[JobResult], user: Optio allowed_job_results = [] studies_ids = [job_result.study_id for job_result in job_results] - studies = { - study.id: study - for study in self.study_service.repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) - } + if studies_ids: + studies = { + study.id: study + for study in self.study_service.repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) + } + else: + studies = {} for job_result in job_results: if job_result.study_id in studies: diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 26a9648b32..6c169a1fc9 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -4,7 +4,7 @@ from enum import Enum from pydantic import BaseModel -from sqlalchemy import and_, or_ # type: ignore +from sqlalchemy import and_, not_, or_ # type: ignore from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import CacheConstants, ICache @@ -20,8 +20,7 @@ def escape_like(string: str, escape_char: str = "\\") -> str: """ Escape the string parameter used in SQL LIKE expressions. - :: - + Examples: from sqlalchemy_utils import escape_like @@ -29,16 +28,18 @@ def escape_like(string: str, escape_char: str = "\\") -> str: User.name.ilike(escape_like('John')) ) + Args: + string: a string to escape + escape_char: escape character - :param string: a string to escape - :param escape_char: escape character + Returns: + Escaped string. """ return string.replace(escape_char, escape_char * 2).replace("%", escape_char + "%").replace("_", escape_char + "_") class StudyFilter(BaseModel, frozen=True): - """ - Study filter + """Study filter class gathering the main filtering parameters Attrs: - name: optional name regex of the study to match @@ -63,17 +64,16 @@ class StudyFilter(BaseModel, frozen=True): users: t.Sequence[str] = () groups: t.Sequence[str] = () tags: t.Sequence[str] = () - studies_ids: t.Optional[t.Sequence[str]] = None + studies_ids: t.Sequence[str] = () exists: t.Optional[bool] = None workspace: str = "" folder: str = "" class StudySortBy(str, Enum): - """ - How to sort the results of studies query results - """ + """How to sort the results of studies query results""" + NO_SORT = "+-" NAME_ASC = "+name" NAME_DESC = "-name" DATE_ASC = "+date" @@ -180,7 +180,7 @@ def get_additional_data(self, study_id: str) -> t.Optional[StudyAdditionalData]: def get_all( self, study_filter: StudyFilter = StudyFilter(), - sort_by: StudySortBy = StudySortBy.DATE_DESC, + sort_by: StudySortBy = StudySortBy.NO_SORT, pagination: StudyPagination = StudyPagination(), ) -> t.List[Study]: """ @@ -188,7 +188,12 @@ def get_all( runtime. Args: + study_filter: composed of all filtering criteria + sort_by: how the user would like the results to be sorted + pagination: specifies the number of results to displayed in each page and the actually displayed page + Returns: + The matching studies in proper order and pagination """ # When we fetch a study, we also need to fetch the associated owner and groups # to check the permissions of the current user efficiently. @@ -197,8 +202,11 @@ def get_all( entity = with_polymorphic(Study, "*") q = self.session.query(entity) - if study_filter.exists: - q = q.filter(RawStudy.missing.is_(None)) + if study_filter.exists is not None: + if study_filter.exists: + q = q.filter(RawStudy.missing.is_(None)) + else: + q = q.filter(not_(RawStudy.missing.is_(None))) q = q.options(joinedload(entity.owner)) q = q.options(joinedload(entity.groups)) q = q.options(joinedload(entity.additional_data)) @@ -208,7 +216,7 @@ def get_all( else: q = q.filter(entity.type == "rawstudy") q = q.filter(RawStudy.workspace != DEFAULT_WORKSPACE_NAME) - if study_filter.studies_ids is not None: + if study_filter.studies_ids: q = q.filter(entity.id.in_(study_filter.studies_ids)) if study_filter.users: q = q.filter(Identity.name.in_(study_filter.users)) @@ -223,8 +231,7 @@ def get_all( regex = f"{escape_like(study_filter.folder)}%" q = q.filter(entity.folder.ilike(regex)) if study_filter.workspace: - regex = f"%{escape_like(study_filter.workspace)}%" - q = q.filter(RawStudy.workspace.ilike(regex)) + q = q.filter(RawStudy.workspace == study_filter.workspace) if study_filter.variant is not None: if study_filter.variant: q = q.filter(entity.type == "variantstudy") @@ -234,14 +241,17 @@ def get_all( q = q.filter(entity.version.in_(study_filter.versions)) # sorting - if sort_by == StudySortBy.DATE_DESC: - q = q.order_by(entity.created_at.desc()) - elif sort_by == StudySortBy.DATE_ASC: - q = q.order_by(entity.created_at.asc()) - elif sort_by == StudySortBy.NAME_DESC: - q = q.order_by(entity.name.desc()) - elif sort_by == StudySortBy.NAME_ASC: - q = q.order_by(entity.name.asc()) + if sort_by != StudySortBy.NO_SORT: + if sort_by == StudySortBy.DATE_DESC: + q = q.order_by(entity.created_at.desc()) + elif sort_by == StudySortBy.DATE_ASC: + q = q.order_by(entity.created_at.asc()) + elif sort_by == StudySortBy.NAME_DESC: + q = q.order_by(entity.name.desc()) + elif sort_by == StudySortBy.NAME_ASC: + q = q.order_by(entity.name.asc()) + else: + raise NotImplementedError(sort_by) # pagination q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) @@ -249,10 +259,13 @@ def get_all( studies: t.List[Study] = q.all() return studies - def get_all_raw(self, show_missing: bool = True) -> t.List[RawStudy]: + def get_all_raw(self, exists: t.Optional[bool] = None) -> t.List[RawStudy]: query = self.session.query(RawStudy) - if not show_missing: - query = query.filter(RawStudy.missing.is_(None)) + if exists is not None: + if exists: + query = query.filter(RawStudy.missing.is_(None)) + else: + query = query.filter(not_(RawStudy.missing.is_(None))) studies: t.List[RawStudy] = query.all() return studies diff --git a/antarest/study/service.py b/antarest/study/service.py index 79b9d4325f..94ea7d4bba 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -441,7 +441,7 @@ def get_studies_information( self, params: RequestParameters, study_filter: StudyFilter, - sort_by: StudySortBy = StudySortBy.DATE_DESC, + sort_by: StudySortBy = StudySortBy.NO_SORT, pagination: StudyPagination = StudyPagination(), ) -> Dict[str, StudyMetadataDTO]: """ @@ -474,8 +474,7 @@ def get_studies_information( study_dto, StudyPermissionType.READ, raising=False, - ) - and (not study_filter.managed or study_dto.managed), + ), studies.values(), ) } diff --git a/antarest/study/storage/auto_archive_service.py b/antarest/study/storage/auto_archive_service.py index 796ae7d0fc..b2ae1fae63 100644 --- a/antarest/study/storage/auto_archive_service.py +++ b/antarest/study/storage/auto_archive_service.py @@ -30,7 +30,9 @@ def _try_archive_studies(self) -> None: old_date = datetime.datetime.utcnow() - datetime.timedelta(days=self.config.storage.auto_archive_threshold_days) with db(): studies: List[Study] = self.study_service.repository.get_all( - study_filter=StudyFilter(managed=True, exists=False) + study_filter=StudyFilter( + managed=True, + ) ) # list of study id and boolean indicating if it's a raw study (True) or a variant (False) study_ids_to_archive = [ diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py b/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py index 3c53c23053..4d34e21637 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py @@ -12,6 +12,7 @@ "RenewableConfig", "RenewableConfigType", "create_renewable_config", + "RenewableClusterGroup", ) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 63ba395f65..30e52e556d 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -59,8 +59,9 @@ def create_study_routes(study_service: StudyService, ftm: FileTransferManager, c def get_studies( current_user: JWTUser = Depends(auth.get_current_user), sort_by: str = Query( - "-date", + "+-", description="- `sort_by`: Sort studies based on their name or date." + " - `+-`: No sorting to be done." " - `+name`: Sort by name in ascending order (case-insensitive)." " - `-name`: Sort by name in descending order (case-insensitive)." " - `+date`: Sort by creation date in ascending order." @@ -146,7 +147,7 @@ def get_studies( users=users.split(SEQUENCE_SEPARATOR) if users else (), groups=groups.split(SEQUENCE_SEPARATOR) if groups else (), tags=tags.split(SEQUENCE_SEPARATOR) if tags else (), - studies_ids=studies_ids.split(SEQUENCE_SEPARATOR) if studies_ids else None, + studies_ids=studies_ids.split(SEQUENCE_SEPARATOR) if studies_ids else (), exists=exists, workspace=workspace, folder=folder, diff --git a/tests/storage/repository/test_study.py b/tests/storage/repository/test_study.py index 03293ce16c..12afde7b05 100644 --- a/tests/storage/repository/test_study.py +++ b/tests/storage/repository/test_study.py @@ -67,9 +67,10 @@ def test_cyclelife(): c = repo.get(a.id) assert a == c - assert len(repo.get_all()) == 3 - assert len(repo.get_all_raw(show_missing=True)) == 2 - assert len(repo.get_all_raw(show_missing=False)) == 1 + assert len(repo.get_all()) == 4 + assert len(repo.get_all_raw(exists=True)) == 1 + assert len(repo.get_all_raw(exists=False)) == 1 + assert len(repo.get_all_raw()) == 2 repo.delete(a.id) assert repo.get(a.id) is None diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index 3e93090af3..e7e8662394 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -44,7 +44,7 @@ TimeSerie, TimeSeriesData, ) -from antarest.study.repository import StudyMetadataRepository, StudyFilter +from antarest.study.repository import StudyFilter, StudyMetadataRepository from antarest.study.service import MAX_MISSING_STUDY_TIMEOUT, StudyService, StudyUpgraderTask, UserHasNotPermissionError from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.model import ( @@ -173,6 +173,7 @@ def test_study_listing(db_session: Session) -> None: repository = StudyMetadataRepository(cache_service=Mock(spec=ICache), session=db_session) service = build_study_service(raw_study_service, repository, config, cache_service=cache) + # retrieve studies that are not managed # use the db recorder to check that: # 1- retrieving studies information requires only 1 query # 2- having an exact total of queries equals to 1 @@ -186,41 +187,59 @@ def test_study_listing(db_session: Session) -> None: assert len(db_recorder.sql_statements) == 1, str(db_recorder) # verify that we get the expected studies information - expected_result = {e.id: e for e in map(lambda x: study_to_dto(x), [a, c])} + expected_result = {e.id: e for e in map(lambda x: study_to_dto(x), [c])} assert expected_result == studies - cache.get.return_value = {e.id: e for e in map(lambda x: study_to_dto(x), [a, b, c])} - # check that: - # 1- retrieving studies information requires no query at all (cache is used) - # 2- the `put` method of `cache` was used once + # retrieve managed studies + # use the db recorder to check that: + # 1- retrieving studies information requires only 1 query + # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( study_filter=StudyFilter( - managed=False, + managed=True, ), params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), ) - assert len(db_recorder.sql_statements) == 0, str(db_recorder) - cache.put.assert_called_once() + assert len(db_recorder.sql_statements) == 1, str(db_recorder) # verify that we get the expected studies information + expected_result = {e.id: e for e in map(lambda x: study_to_dto(x), [a])} assert expected_result == studies - cache.get.return_value = None + # retrieve studies regardless of whether they are managed or not # use the db recorder to check that: - # 1- retrieving studies information requires only 1 query (cache reset to None) + # 1- retrieving studies information requires only 1 query # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( study_filter=StudyFilter( - managed=False, + managed=None, ), params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) # verify that we get the expected studies information - expected_result = {e.id: e for e in map(lambda x: study_to_dto(x), [a])} + expected_result = {e.id: e for e in map(lambda x: study_to_dto(x), [a, c])} + assert expected_result == studies + + # in previous versions cache was used, verify that it is not used anymore + # check that: + # 1- retrieving studies information still requires 1 query + # 2- the `put` method of `cache` was never used + with DBStatementRecorder(db_session.bind) as db_recorder: + studies = service.get_studies_information( + study_filter=StudyFilter( + managed=None, + ), + params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), + ) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + with contextlib.suppress(AssertionError): + cache.put.assert_any_call() + + # verify that we get the expected studies information assert expected_result == studies diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index 975190339e..8bf99eeeed 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -15,26 +15,33 @@ @pytest.mark.parametrize( "managed, studies_ids, exists, expected_ids", [ - (None, None, False, {"1", "2", "3", "4", "5", "6", "7", "8"}), - (None, None, True, {"1", "2", "3", "4", "7", "8"}), - (None, [1, 3, 5, 7], False, {"1", "3", "5", "7"}), + (None, [], False, {"5", "6"}), + (None, [], True, {"1", "2", "3", "4", "7", "8"}), + (None, [], None, {"1", "2", "3", "4", "5", "6", "7", "8"}), + (None, [1, 3, 5, 7], False, {"5"}), (None, [1, 3, 5, 7], True, {"1", "3", "7"}), - (True, None, False, {"1", "2", "3", "4", "5", "8"}), - (True, None, True, {"1", "2", "3", "4", "8"}), - (True, [1, 3, 5, 7], False, {"1", "3", "5"}), + (None, [1, 3, 5, 7], None, {"1", "3", "5", "7"}), + (True, [], False, {"5"}), + (True, [], True, {"1", "2", "3", "4", "8"}), + (True, [], None, {"1", "2", "3", "4", "5", "8"}), + (True, [1, 3, 5, 7], False, {"5"}), (True, [1, 3, 5, 7], True, {"1", "3"}), + (True, [1, 3, 5, 7], None, {"1", "3", "5"}), (True, [2, 4, 6, 8], True, {"2", "4", "8"}), - (False, None, False, {"6", "7"}), - (False, None, True, {"7"}), - (False, [1, 3, 5, 7], False, {"7"}), + (True, [2, 4, 6, 8], None, {"2", "4", "8"}), + (False, [], False, {"6"}), + (False, [], True, {"7"}), + (False, [], None, {"6", "7"}), + (False, [1, 3, 5, 7], False, set()), (False, [1, 3, 5, 7], True, {"7"}), + (False, [1, 3, 5, 7], None, {"7"}), ], ) def test_repository_get_all( db_session: Session, managed: t.Union[bool, None], studies_ids: t.Union[t.List[str], None], - exists: bool, + exists: t.Union[bool, None], expected_ids: set, ): test_workspace = "test-repository" From 48684c1765da978d1d37582bceea9bb652ac6117 Mon Sep 17 00:00:00 2001 From: Mohamed Abdel Wedoud Date: Tue, 16 Jan 2024 15:05:15 +0100 Subject: [PATCH 03/13] test(study-search): add unittests for the filtering parameters --- antarest/study/repository.py | 21 +- antarest/study/web/studies_blueprint.py | 6 +- tests/study/test_repository.py | 524 +++++++++++++++++++++++- 3 files changed, 536 insertions(+), 15 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 6c169a1fc9..1ba4bb62eb 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -3,15 +3,15 @@ import typing as t from enum import Enum -from pydantic import BaseModel -from sqlalchemy import and_, not_, or_ # type: ignore +from pydantic import BaseModel, Field +from sqlalchemy import String, and_, any_, not_, or_ # type: ignore from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import CacheConstants, ICache from antarest.core.utils.fastapi_sqlalchemy import db -from antarest.login.model import Group, Identity +from antarest.login.model import Group from antarest.study.common.utils import get_study_information -from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData +from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData, groups_metadata logger = logging.getLogger(__name__) @@ -61,7 +61,7 @@ class StudyFilter(BaseModel, frozen=True): archived: t.Optional[bool] = None variant: t.Optional[bool] = None versions: t.Sequence[str] = () - users: t.Sequence[str] = () + users: t.Sequence["int"] = () groups: t.Sequence[str] = () tags: t.Sequence[str] = () studies_ids: t.Sequence[str] = () @@ -73,7 +73,7 @@ class StudyFilter(BaseModel, frozen=True): class StudySortBy(str, Enum): """How to sort the results of studies query results""" - NO_SORT = "+-" + NO_SORT = "" NAME_ASC = "+name" NAME_DESC = "-name" DATE_ASC = "+date" @@ -88,7 +88,7 @@ class StudyPagination(BaseModel, frozen=True): """ page_nb: int = 0 - page_size: int = 100 + page_size: int = Field(0, ge=0) class StudyMetadataRepository: @@ -219,9 +219,9 @@ def get_all( if study_filter.studies_ids: q = q.filter(entity.id.in_(study_filter.studies_ids)) if study_filter.users: - q = q.filter(Identity.name.in_(study_filter.users)) + q = q.filter(entity.owner_id.in_(study_filter.users)) if study_filter.groups: - q = q.filter(Group.name.in_(study_filter.groups)) + q = q.join(entity.groups).filter(Group.id.in_(study_filter.groups)) if study_filter.archived is not None: q = q.filter(entity.archived == study_filter.archived) if study_filter.name: @@ -254,7 +254,8 @@ def get_all( raise NotImplementedError(sort_by) # pagination - q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) + if pagination.page_nb or pagination.page_size: + q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) studies: t.List[Study] = q.all() return studies diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 30e52e556d..8480f04257 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -59,9 +59,9 @@ def create_study_routes(study_service: StudyService, ftm: FileTransferManager, c def get_studies( current_user: JWTUser = Depends(auth.get_current_user), sort_by: str = Query( - "+-", + "", description="- `sort_by`: Sort studies based on their name or date." - " - `+-`: No sorting to be done." + " - ``: No sorting to be done." " - `+name`: Sort by name in ascending order (case-insensitive)." " - `-name`: Sort by name in descending order (case-insensitive)." " - `+date`: Sort by creation date in ascending order." @@ -69,7 +69,7 @@ def get_studies( alias="sortBy", ), page_nb: int = Query(0, description="Page number (starting from 0).", alias="pageNb"), - page_size: int = Query(100, description="Number of studies per page.", alias="pageSize"), + page_size: int = Query(0, description="Number of studies per page.", alias="pageSize"), name: str = Query( "", description="Filter studies based on their name." diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index 8bf99eeeed..abe422c479 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session # type: ignore from antarest.core.interfaces.cache import ICache -from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy +from antarest.login.model import Group, User +from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study from antarest.study.repository import StudyFilter, StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @@ -37,7 +38,7 @@ (False, [1, 3, 5, 7], None, {"7"}), ], ) -def test_repository_get_all( +def test_repository_get_all__general_case( db_session: Session, managed: t.Union[bool, None], studies_ids: t.Union[t.List[str], None], @@ -75,3 +76,522 @@ def test_repository_get_all( if expected_ids is not None: assert set([s.id for s in all_studies]) == expected_ids + + +def test_repository_get_all__incompatible_case( + db_session: Session, +): + test_workspace = "workspace1" + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = VariantStudy(id=3) + study_4 = VariantStudy(id=4) + study_5 = RawStudy(id=5, missing=datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(id=6, missing=datetime.now(), workspace=test_workspace) + study_7 = RawStudy(id=7, missing=None, workspace=test_workspace) + study_8 = RawStudy(id=8, missing=None, workspace=DEFAULT_WORKSPACE_NAME) + + db_session.add_all([study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8]) + db_session.commit() + + # case 1 + study_filter = StudyFilter(managed=False, variant=True) + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + assert set([s.id for s in all_studies]) == set() + + # case 2 + study_filter = StudyFilter(workspace=test_workspace, variant=True) + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + assert set([s.id for s in all_studies]) == set() + + # case 3 + study_filter = StudyFilter(exists=False, variant=True) + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + assert set([s.id for s in all_studies]) == set() + + +@pytest.mark.parametrize( + "name, expected_ids", + [ + ("", {"1", "2", "3", "4", "5", "6", "7", "8"}), + ("specie", {"1", "2", "3", "4", "5", "6", "7", "8"}), + ("prefix-specie", {"2", "3", "6", "7"}), + ("variant", {"1", "2", "3", "4"}), + ("variant-suffix", {"3", "4"}), + ("raw", {"5", "6", "7", "8"}), + ("raw-suffix", {"7", "8"}), + ("prefix-variant", set()), + ("specie-suffix", set()), + ], +) +def test_repository_get_all__study_name_filter( + db_session: Session, + name: str, + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1, name="specie-variant") + study_2 = VariantStudy(id=2, name="prefix-specie-variant") + study_3 = VariantStudy(id=3, name="prefix-specie-variant-suffix") + study_4 = VariantStudy(id=4, name="specie-variant-suffix") + study_5 = RawStudy(id=5, name="specie-raw") + study_6 = RawStudy(id=6, name="prefix-specie-raw") + study_7 = RawStudy(id=7, name="prefix-specie-raw-suffix") + study_8 = RawStudy(id=8, name="specie-raw-suffix") + + db_session.add_all([study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(name=name)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "managed, expected_ids", + [ + (None, {"1", "2", "3", "4", "5", "6", "7", "8"}), + (True, {"1", "2", "3", "4", "5", "8"}), + (False, {"6", "7"}), + ], +) +def test_repository_get_all__managed_study_filter( + db_session: Session, + managed: t.Optional[bool], + expected_ids: set, +): + test_workspace = "test-workspace" + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = VariantStudy(id=3) + study_4 = VariantStudy(id=4) + study_5 = RawStudy(id=5, workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(id=6, workspace=test_workspace) + study_7 = RawStudy(id=7, workspace=test_workspace) + study_8 = RawStudy(id=8, workspace=DEFAULT_WORKSPACE_NAME) + + db_session.add_all([study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(managed=managed)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "archived, expected_ids", + [ + (None, {"1", "2", "3", "4"}), + (True, {"1", "3"}), + (False, {"2", "4"}), + ], +) +def test_repository_get_all__archived_study_filter( + db_session: Session, + archived: t.Optional[bool], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1, archived=True) + study_2 = VariantStudy(id=2, archived=False) + study_3 = RawStudy(id=3, archived=True) + study_4 = RawStudy(id=4, archived=False) + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(archived=archived)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "variant, expected_ids", + [ + (None, {"1", "2", "3", "4"}), + (True, {"1", "2"}), + (False, {"3", "4"}), + ], +) +def test_repository_get_all__variant_study_filter( + db_session: Session, + variant: t.Optional[bool], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = RawStudy(id=3) + study_4 = RawStudy(id=4) + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(variant=variant)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "versions, expected_ids", + [ + ([], {"1", "2", "3", "4"}), + (["1", "2"], {"1", "2", "3", "4"}), + (["1"], {"1", "3"}), + (["2"], {"2", "4"}), + (["3"], set()), + ], +) +def test_repository_get_all__study_version_filter( + db_session: Session, + versions: t.List[str], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1, version="1") + study_2 = VariantStudy(id=2, version="2") + study_3 = RawStudy(id=3, version="1") + study_4 = RawStudy(id=4, version="2") + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(versions=versions)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "users, expected_ids", + [ + ([], {"1", "2", "3", "4"}), + (["1000", "2000"], {"1", "2", "3", "4"}), + (["1000"], {"1", "3"}), + (["2000"], {"2", "4"}), + (["3000"], set()), + ], +) +def test_repository_get_all__study_users_filter( + db_session: Session, + users: t.List["int"], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + test_user_1 = User(id=1000) + test_user_2 = User(id=2000) + + study_1 = VariantStudy(id=1, owner=test_user_1) + study_2 = VariantStudy(id=2, owner=test_user_2) + study_3 = RawStudy(id=3, owner=test_user_1) + study_4 = RawStudy(id=4, owner=test_user_2) + + db_session.add_all([test_user_1, test_user_2]) + db_session.commit() + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(users=users)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "groups, expected_ids", + [ + ([], {"1", "2", "3", "4"}), + (["1000", "2000"], {"1", "2", "3", "4"}), + (["1000"], {"1", "2", "4"}), + (["2000"], {"2", "3"}), + (["3000"], set()), + ], +) +def test_repository_get_all__study_groups_filter( + db_session: Session, + groups: t.List[str], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + test_group_1 = Group(id=1000) + test_group_2 = Group(id=2000) + + study_1 = VariantStudy(id=1, groups=[test_group_1]) + study_2 = VariantStudy(id=2, groups=[test_group_1, test_group_2]) + study_3 = RawStudy(id=3, groups=[test_group_2]) + study_4 = RawStudy(id=4, groups=[test_group_1]) + + db_session.add_all([test_group_1, test_group_2]) + db_session.commit() + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(groups=groups)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "studies_ids, expected_ids", + [ + ([], {"1", "2", "3", "4"}), + (["1", "2", "3", "4"], {"1", "2", "3", "4"}), + (["1", "2", "4"], {"1", "2", "4"}), + (["2", "3"], {"2", "3"}), + (["2"], {"2"}), + (["3000"], set()), + ], +) +def test_repository_get_all__studies_ids_filter( + db_session: Session, + studies_ids: t.List[str], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = RawStudy(id=3) + study_4 = RawStudy(id=4) + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "exists, expected_ids", + [ + (None, {"1", "2", "3", "4"}), + (True, {"1", "2", "4"}), + (False, {"3"}), + ], +) +def test_repository_get_all__study_existence_filter( + db_session: Session, + exists: t.Optional[bool], + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = RawStudy(id=3, missing=datetime.now()) + study_4 = RawStudy(id=4) + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(exists=exists)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "workspace, expected_ids", + [ + ("", {"1", "2", "3", "4"}), + ("workspace-1", {"3"}), + ("workspace-2", {"4"}), + ("workspace-3", set()), + ], +) +def test_repository_get_all__study_workspace_filter( + db_session: Session, + workspace: str, + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = RawStudy(id=3, workspace="workspace-1") + study_4 = RawStudy(id=4, workspace="workspace-2") + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(workspace=workspace)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids + + +@pytest.mark.parametrize( + "folder, expected_ids", + [ + ("", {"1", "2", "3", "4"}), + ("/home/folder-", {"1", "2", "3", "4"}), + ("/home/folder-1", {"1", "3"}), + ("/home/folder-2", {"2", "4"}), + ("/home/folder-3", set()), + ("folder-1", set()), + ], +) +def test_repository_get_all__study_folder_filter( + db_session: Session, + folder: str, + expected_ids: set, +): + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1, folder="/home/folder-1") + study_2 = VariantStudy(id=2, folder="/home/folder-2") + study_3 = RawStudy(id=3, folder="/home/folder-1") + study_4 = RawStudy(id=4, folder="/home/folder-2") + + db_session.add_all([study_1, study_2, study_3, study_4]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=StudyFilter(folder=folder)) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert set([s.id for s in all_studies]) == expected_ids From f8551779f0ea4f960683ccdd1f8fdf0f4034657b Mon Sep 17 00:00:00 2001 From: Mohamed Abdel Wedoud Date: Tue, 23 Jan 2024 13:47:11 +0100 Subject: [PATCH 04/13] test(study-search): add integration tests for the studies listing --- antarest/study/web/studies_blueprint.py | 2 +- tests/integration/assets/ext-840.zip | Bin 0 -> 61075 bytes tests/integration/assets/ext-850.zip | Bin 0 -> 63814 bytes tests/integration/assets/ext-860.zip | Bin 0 -> 64377 bytes .../studies_blueprint/test_get_studies.py | 637 ++++++++++++++++++ 5 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 tests/integration/assets/ext-840.zip create mode 100644 tests/integration/assets/ext-850.zip create mode 100644 tests/integration/assets/ext-860.zip create mode 100644 tests/integration/studies_blueprint/test_get_studies.py diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 8480f04257..09825f6761 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -114,7 +114,7 @@ def get_studies( " Provide a comma-separated list of study IDs for filtering.", alias="studiesIds", ), - exists: bool = Query( + exists: Optional[bool] = Query( None, description="Filter studies based on their existence on disk." " - not set: No specific filtering." diff --git a/tests/integration/assets/ext-840.zip b/tests/integration/assets/ext-840.zip new file mode 100644 index 0000000000000000000000000000000000000000..ac81e0ca0cdaffb58490f81ffd22b55734cc1d6b GIT binary patch literal 61075 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

C1@h~wX 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 literal 0 HcmV?d00001 diff --git a/tests/integration/assets/ext-850.zip b/tests/integration/assets/ext-850.zip new file mode 100644 index 0000000000000000000000000000000000000000..ff25db9c382c80b4a1dd46eebd366ffe1a8da638 GIT binary patch literal 63814 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�dV)`Kc++RK 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 O7u{t50BhmfPyYwnmBfpO*aBh}HL zn*SFhEC7k)??~u>w}$u^Bzs$g8<(4x+uv+s!#_2L{(IYifPYx`JLZf0uP*v1Ojp$S z6%%3UhTyVwwEY8<^xtEO_m|@$c_1>Q^I8^Ya(N~oB^eiVB3akF>;jlGE1+r5TY(5h zNZ5Bbc;j)(`X^rn`O=q}28-_wf}pN6Sxfe;%eU&+9*yE{IA$suFRkmMgyP{@8^oNW zIRCH}0Qi+j@;~NpX6tBS>u7E6 z8u$J^pt+N)tEIiEo2`@MpG^J_dW%+5A5`X48i-u$8&p?(T=rODpr2iJU}+is4{-wk zp1)B2>s%0)uC|tcm&(2W&Nu+T=J4mW`xVgK$-%+N@%JS>*#56e_&))uej(@Yfaa#o zrslS8-v0;4zsij47i3d=dnfb1(fyYd2LNE3{CWO3e}LLMnOgh~B=uK_{8eRl{|xlM z78(7&#ruDuv0o|qWhVbBvA@I&*)J6TGh85?>`h&N=h*oFhU0&V^>?KIHOc?n$$tX; zf5Gyvto%Bcf2H{^k;C;1&3JzZ#?8jk)xp&MccLTzJJG-UVWXKH|9dF!#*P~Q+-m>8 z`dg&`OKy#8kX?KLi4*OI=6|L5FEL~KOYHooAF{V~wEMfH zPQUzx7-#hh68;}s&A}F7&S`D=_kfA^R~F3XKZ3bhqUPxFk8qCpSHPgZr1yk>ScY)p zM7TM*np#`_E+)FalIfHE0!Q)h;QqWPY5ofCuVzC39qzv_@L#g_SLuUMsP?Abf4Gak ztoNiqNq#<*+mk_!-x&Nm$nPJ2$>|L#mZ)&D#jQQiD0}QY(7P0+roz?Bp?rdd-R(;6 zBK8!?2KgMtd(zG&Z&fG0Kg7v z{N^qHfcY&Qv_w@mf2#8SLxyab_|P$!hd}mBIFx@*0A8rA%BJG+vd$3!0E5hXy%N%i zH@EEQWD2Xu#>H+gC<2O#nyQy6{p@-aCuOm*+=NNGY_T^=&%UFnO);M^mqLB~6XGak zc$`0kMAdpnuYBAo__(i5Y=X;d z25qu)mg3uI=3wknLD~LbY9}Yla5)_g(`F&Vr-Abn;h`ZGP?&-luN(xr)u^=Wbj|r)LE0 zN)2c)9Hvc(l-J@}3~(r~Y^CuZ2HQLyVhs<7PL1yuX#Phbi3Rv=snTGYc`HBwz#1C> z_-#Y|!7<7oS-P6qTbR0;{vqDqR;=dVubkiGeh*4P$lMS1W8iW5j*u$p9RJ+wOskC* zOj#+4C%!7@3F0@GM$R-8wpkwwTQLgO z`P038a3c|6@8|w*61wNA`F56q6`DpPDH$2?xR?#s6F)t>soP|X4o_h1o*(4(3jqZQ zvc*-A0?$&J`{zbMRB!!{ek#|h1WB&?C+NrpKe&^=W-wt`kgBJp(c9H&#LF}#$}cqY zfbzp@hWI<5IC`HuwYxlwBaT-*nd{cIsfptt*})Ds=;LDhnx;ot-&**&ylJ}3T~?r% zy{3F)Q@<1c-YET24^9W`q(%~Y{hQFE`$|tGiW02E*#$Se0s^8rA|3OEXE-aAym7d^ z1r^evvtvOh#7kAOrTyHE3&Gi1ztJ#(#oax7; zKuhK}AL8=BnySAb`ARpq_d%kT)3d%^O_SgUpYNGjp?~cPZr*_%gw&2D*Scrby?9kn zp!#Jroyz!w%gRS_ssab{DNWVMcHNJDZclzZ(a@J4Ho)GhYnFywNONP1i1*kGjNWfMJ5;muS=P6be2P~XEB%UWsiQYkERNLnnpOzz z1bRpsaasq@Vaue%6s~eV^^KOM(!9k^(- zl|d3zs3Ss+-^%qr90$VO($Un_*2&D>*51O>l?&xT{#(~9+Cx!QnPZSu6N_z7kyDKm zmFoX%|KisW{ae-Z$Lmn}j+48q`9Bh#zZ|Y@ryc0gMgf47W3)fp|4%@Ko4bWKm#w+e zANK!;zSgcESXW!;#|^sjKDB0HL;+GGMSB$k>0Qp>!gx8Jr&|JNJZM~1D-i!UKTZxA5mJ2`OAU5K7qa^wWtpL8lxr`Kwg@Iq~GFvccV^tom~25BJ?2_AmnN+S5zL%f{|d_dSb< zTjOt{6paWbZLRVjtGZ}qvze0NCl*w|9i;7(#ct=<2x}IYrm~KC7g8!)^?tXv$`*ZM zXsaklP86ZBh|k92SO&U-*eD3*0-LaIl=Ynqj=4DqG2YP!c)r8=sJ&99(QeF zby|mPr;xVi&YE39;%;<c`T~KRpyF{#sD4XQTo?<+q{88IhKl2 zfeWAVc>1Ps%(>~YB*S_MDWcGyg;75Z-si<8g3gUH^K75i+H67njwMvj?wS7TG}0s4pUcT6wDh~o$I$XjPY2a7evu^e zU^cT7jpBNc5~W?tM|+z%$HjeL9|Ze@{UyWu`F5te${*@aJMM_OS-BH^5-%rY!>W`K z?0b4x^Gq_-@Hhg>#x0=1(*lUm9%dyNn^udMzX*T69!#hPMWLfO- z#^86FsqhTZl$@DL$32z6+8@Ds1JvvyHRJ(%z~kA;aq3=MjOs7cGh5p>_|Nt*A&B7X z!u2PFIigvev*+aKGmv4zbV^BlbVM-qB%_evB7RQL{j@nkzpi*Ex%Eoh*n_2p0 z1o!DUd~LL@BR;9NT+)!VQ2vJY+|_khi?vozCL_O-;bbInwyst^asapKiSype7dz`l zt8dxda&^>3?`Hb=1i|!q=a3E7R_eGnv!7-S)(!H%eN~Oby^km*%%R*B{yKZnYRFgp za<`T~4(Wrp&wfX+3n55{2o3-(xKQ^5<1(KkvU^xn_k?UF(mvpkbC zYkz;9LsZL-vEdj^vul5!Pp~7iKdAVMWcN)Nvxf%LkB75$_j|Ku5eWv)jxFAgU8P+I zr^(khIdUk?zC?4Zhv{lLJ06(IE+vbjWj-Nxgs+F6S319ed5dSx{sTbSm&kfjvJ4}22*pP_6#_AD&9W8P*ufO>$ zvS-#{XFUvh?HM8N4dd1-srFAHboyW-v;6ub@r{k)jEGMbj}-olKIO9HRxLNFa3VkC z=PgMqsn=eL6ylays0QA^&T15Na%YlH#3*VrHn)6l?75xTem%$Wm+mGteDit2QrZJ? zZWrcZ@}JFq%9(lf1~UmKXaReT%di@ohEL&Sg1biciaETxv{DTiXX3=e_%}r3Jj&gR z`=fD}G-Y9tMD>+H z%lU6c+u~$YcQ>pezAat^T?lsa7p!+x8`|tq9}TLOYK5++5OQa;+H{@ej?I1`$)Nonu@@4B}0HUghM_*)W%~lg}zkQ-Ie|D{)N2e z1w_{qt_EBV&|Q$G!rQTr2&eA`*rD~fv^)nKQQ5h~VAbMP+=QmF8WK4~jI@>GRN--D zzmoP9?&x3CqBjEPHl=SY(1l^#-hpvU`b3!1Hvz7~uYcCQvxwHP57%8l9^?wk5jG0h zCJ44xFwjqddHZvu`X#~|ckR3@q=)hqG*0y@ECfyNi&i)5dfeSWa&{cbQB*6+J+N>J zwrp1Jr{QF~(y*FJtw{7^V-hXFfuFiZ_0be1?2b`9Xl#9I|AHKW6q_=>(xQLrNtwzTXD%4)&JRyn%XpGxKcULqE%V}rN8?^rQ##7Y zPlJ=hgF<)9QFfRoUn7fm^~Nqi-PsB9>ykK?i{ASTTs{qXyE#dg!=S1ASp9gys;3op zy!|u`&ASAM?_@8Tilj znUk;EO_(YC;d|FIm$h^^ET&@O!o%C5hA_ns^W{v79l_g*`=b@{Ph>o}n64meJ$O5c z=@mu&OrpKECqbXR?>+vmJ*FVVAaEhqx1wG~wbypu`wDc{JiF`44#niR-P zeGjn+7sE;Pi}<{o(5z0a`b~8;MLZ@tmy-YOQ6EJDgUZRPZ7%e~60-_!>!e0(z4+c_ zzY^aH?xRQMC0i^abk=FBuNb&QBe%Bii>~_QsdX~?P;g!ec~n@(>qx&4DNhysR8Mg# zd_HwB)*&H>v-40(;jBfOz*lk%NT<`Ue`@)~QnI_>ohi;FMLak~9A3Svp;fRWayNIM zRvVQtx+j6j^xGd&17_+P=$_vARNmh_gf(*wUGM`kJ6ack+8I&1wjKX4Fp$uwGZiyM0~X6j_;-A}N4h@!>!&-IK2 zdK1R5m%aHFU{kR9bPGw&haNo}_hXaq`Q#B#l{>|6-`Ddf-hUl4PR9>;Td~4FyKQ^v zdwutUA-2P!2-a*JNfJf-P8wuRnb zfm344a8-=+6kn{Eji%EEhPxFC@$Hb;`N0|*?DGxp5BP*o$wB~(D#>$v31Y7FwUA#N0 z?UM5F@0LR3H-x6`qL+(}E#<4RFkewjRY;2~&l^51!SE3bD1P4P>VCYz7(f1!t^PnH z?BV`Jq{W5;LG<2dWCFp|9c1K6))VI?je70Hag6(#Exo#L*^cK16 z?v0Z`y?qdri+!TG_$nwKh=(!8W(xnnXP!m>RuA3jMb$24nq|2DgPDfTN5(F@>ich= z?E8{9$L>^00OAh%-dddtm&=AOrA*T5-gU%E^lNmR%HL}juqK+4pWRqf9eX5X{FvOX zZv&6rC*)*=F^kT&Vqy84&eZyQ89$niDn=*zqTLyyfrAW#>e zmmfh2<@wI>CI9`AGPN^$=DR;0w^e^+Eq4#E{C<%|2yK}Vsu_=bc=|-DLb@?k^&?li zv7tXyn8>Ze!n#kVGh)#%Kb6aW?vc&+Chn@Z@N$#SA3JJAIGm1v8|9cyszjFMSyJ-lBsh%A5QgeWGD_!A?LTw_LUtv(n`^5 zhsgv;BG*U~{O)&hCUVr{E#ak@-h!&tu$pr+q-J+Z08e34twT7|BDT+q5CWZ*+sh`4 zvaq%H9TTv76{(e})aOAJ7Tn!84i}83GgZ&t(JaM}Z#}^@=FX}lJa&2O|GebKV?Hpx zS8CB#LuiG?0zHT5K~!O{T6ZNo^A1)oenoJs@$LssH+%t^M?T{Mo#QU3HGDV}=J1hg zv~sU$Ppu-c$^W(GMmPUaFCG=G{^{L>r%Llu8vZ>xqr+DX#@lAV3J-NY`Sg`zOlSKJ z5goAIXH^$>f0O*0K({Gnrr!CCm%A<2Gd(Y!J{bae^&g+XkeaM$(p5i1{v`&V=<6F@%wIe=VHPVtU z*_ig5v3&KeLAqrCqpSz?$hrSZ*I4I$%8j&$!}p}#yrtbOMS)JKg2wzs0oe^wDTwJ-{6?d1ZwS?k7S@ljOW*^#q6*_T^tGls{S2%<7!SbQ4h_+ z*RaVm{zR05_9Xr026C%LUY`v4qLebX_Q4!#xxk*;{vNBwetnqY=V!HK^H0w*uy3zY zhASD&6^4xm?cloUQa6x=(N&cTsE>cb(z@-!KU6%_TmLQG$|z*RNIv^=?%9O zD>>42z|alk19W<{att+V!h#z?Nv;AW`=+_KVN>J9BJB@*#-DO{$hAt2e|XpyFfNCk z6DH(gGxVadQL3U{BiY~yY&dIoE|MmXDLMC}np4MZPFZuX?e%k>uWB~8S%t95BGOTD z{*E7oXy3mbG`hUDAd9k_{4u`M&N)MD_t0K+H=1}rN0n5!TUozeI*#-xS?0`fyU;1H z^i5H zy{h~Uqw3%~;>f_|L-kGf4t=@sn+YdVU_PthF7#rA(z)!%h>?@OTurCeue zPwljFUeR2zS@y+&duaOPJZvx6)_S>eN-yIo+Oaxe+0;bMO7p%i8U0@tR4DgPvA87o zCF-8?F*~NqOzFAyH_10CMKZ+B--cQWwpA=V7N}97nOf_eVlkcVl(yk!NomAQxqkhi z)-p(p(sFe>na@b#

zBe;(9*g@OH z6YHC^mO%0`G`LHmE8o0N30scmF@Y0O6~A{8a&Qn5 zzZP*FAKo7HCdBb0f$!6dHQ7Z%p?x$!i`;D+Y$)_r-3+b;gABY33KZ&u!Cf!Bc2x!B zzbSK$Btmf9Smusarl;P3yU!-FO{|Ka9jGtnz2q;FABoSphF!BZ%AdU75k`Ioe6tI# z!Ot`?;i=+IjSpN(f+Y;hTGOB>^b?zW88iN_JkKLG%JueE&jLSY25hiX{E zDgfZiAKRa?+*EVMwroDKKCQgl2`?aqFlt^nJnDaCm+nQSQX_L8L7Dd!UwGhW8F$-ScrIWGZx|sZRu#Y)=tu%BP?Lj)fW04&1s|Az&m^oV7H1_0??!x( z95~>EXg>ZRiRVnUNIjSOgY+2hI*id2d5U=#*0M9$MhdjxmUJp*nDGLfb{&|b)$St2 zpd(iCXz5MJFU=|tjuar!*4>8?---`jq{Anp_jRx$=9ux-LsGoyDQ7wPlQmz=xyn{# z8aI?#oH;ox5aJEv3B?3$Q{(f@1m5u%%i}f{%aegn<=HVD`)dc*Elxd-)$lKr%`cbW zB`sXab6^;v3ksYUGagB$2Cd_mnS5S81bhc8IN@7Pq6t6=%7BzxIo2G0fxfVO4pEb? zO`Ovso-*#&alo>Gx34WA2hM=1;2#$PETUs#2BD_jtk~e#Sm7bWrSr}5Okk6{I2qGL zkQW++@!)ct0D|N`FpEcf!;f}cf9?pyvJOP^7b|8+pd|PNd=9BaLxT*Iu6*xnYuW1@ zI7AYF9@#<^YJhIvBR*2*7|cNj4+QR*1_K@3tGU;qh2;(HpDCaDle{Cr>IAws!P!8J<9DDubWF^%RT^%pc`P8^$-r5WJ;oR< zc8Ad>x6G?IVb3lLgv!P?CKrVk=cp)v9Ls2L^jq(>>Z8R5jvjRb`?rj8QJg$tCy2-X z7P^a+fhd%a@N@vb@X%E~G2t))szAp>(V+?gWX5D{L4j1J5UMp(z-Q)`+@?ns@#tXk zShCKC0c2`kvjA48i5{=vfChIGB``)<3iWFRkauk7boLepI{5|*lv36L7x58w*6zR? zfdb|SYxzBj#qyUl9aKq3D8TxLaofp`Sqyoe5};%a!H@W$&3P{~e(A0>jRq?P^E?Fz zP`|ASP5~PA3}Im!x_!sN@8?(ad=FT$G&=|;anwEx4YwyX41PJ z67j0B+k@xjcukQL?{jcLNpj;SpO~QskHe_X@55Ru963To=_v4jQbGx0!D&1I!yR^6 zJez5(6&?z(UzTuw3#~~9Kv^_T8={gjNSzRZ7Wp$V00@kH#6!oaUeZ$sSAzg(58L#? zpG4lZT2X+_>z$S8K^hs{#rHu+dZu|?G$untIEV;3QkLvjq3$1I-Eo0KW9{ZSc;i)E z0V9{Gp=gn_XyiW$Elm~)0-PR7ce&G3LNqo?MDqN|tDC%Ic=@)SSij?C$696a6@lP|Yk1*N`$pn%? zgxr}zlK|~@z!HpWYG^>T;2@7Y8mfCRh%fjCYk|zrTxY?r)l#?3mE}zW`5TV}c zcxa?5+*40};*`7I_MD9Bc;|oy>WpiM1H7SGARRJYGpjEOy<@#?J}2GmK2no(&Vb$o z7XYOpLZVEO(*a5=NJ8KpS8$Uc=uSEHBmtTPoZ2#Wqd?g2TTdlk-5d9z!ktUVL;IEm z`lq!lon&Up(k13v54H#C?;c5B?xO(4M{ilb+TWdY^{-EbS$U-U2t}_xfjqws$JdrP{7dwM@VT&!@fZe%#mppB} zM5+eYYN~>hrDSd-uN$gky(D>do4ohhG^mIk&>emItW zOCPHt@geWp)z0f%(l!a28}TjkyIgG;jrP<42Q1!LR@+x9Q5WNd@W(YBs8FE{mDFSF|igb=(X6d1AePsh8AwnI@_8IT=z7O~u zHZH!3dpE3IFJ`)!?~WVZ1-;)V{(0oj%xRr_ISfIDaLvyWk`sVjoXgnF*Kya4tTfsx zSmgl7;)|C9FuARp=QgN=A6IG7Py@L?A*_g??j%Ysr0eTZ30dZZ!Mh;R#gzE%X1rqJ#}sxZh*zvJ@aIu z;^+pn1k`+%(b0dP$5|c+f*fQ_=-%&F5f`A;T#M*rXNt49oH?2zH+btUQ#UN!R@V z2LP+woVYM|0dvD)U1-!qT$<>hCq3;9K**vO47jhgrd&OmDRSh5xh*O3PqH0DPo|K^c7O0DP5Kx>c~^?Q z)x^jy;Bpl@Ig}+K7HxAvVFkI5liW&g*3XKUzuXE5;Pv@J&!SMDTUmVe?Z-+uSt( zh`IW!KOm*L*9jZZh5}{Xp`RI%!G-&6^hrbPV8|*)aU#NFEfU-c!f@AaJ&a%;hrPai z#N-muLR%R7?IA-(#0FN2K+A>I)gz1}Y1bq9xV6?=1zd%N;~t3&nX^2EqVFR;cXLY< zz~%TCAq|SughRT;7HV!tz2MWQS1(w)oIP@CvC4 zsDBI_#@Kn}f=9S{rEA1M6|k9qA?;q4v$G6{gzDa0^Em4Pz(22A;`&rJU6q;lY(*@5H-=1B+O zxS`M`kIJal9&hv#i%Wm=ZQnFr%hh7TJpb2E+kt3$Vih7cSRKgdhAKs0y341FRS`xM zjOu0aOrVZ!S-qg2ga`Nnx(l(I520fpKa>wSOqrTg6>}#9p%EH8r-ui!yK%R~x(m@Y z7XQT9?pisPFR0ieYxL&|ruzJUaw!qc3Q)X`wM2jN(!6zxtzf=VOI z$Xw(sEQ<_$f^J1aju2}ZpnqpT*775OuLdR@yoPmyt83B$Z(=m26Q=pBt39#*7rJq^)ui|YPktc8XiHNdcf!aTy1HoMPK?!kkzce zfir0exNJv>ZjwyJG7cN_qR<39uA+5+7N+NoetuiVMG%E;c}!mbD^uiz+39H2vyMx%UqtDQ(M zm7W;1#iVuc`&Z>3EW5*yL_LI&m*`zId?ia zXi9~{96LtR-kRctc*KCJAjaKSo}0i+j5?5kCu-=mDCubEPjKgS0ghvfT;K@sYBzF~ zrmC!_kWME1;B#Rorkhv}WwGM3&Rb?so)P3{{>Wb2j$99GJ9(DnsFiNbc!)H712g}L zo39>2$%LG4kpQDQ)W}(B8{d2BLoa@>X~x3&`-oI&?(HBp&Tl+(rO(U!il} zFdenoNi+m8Ajyh5$}ClmhPOuA31moJo&&Jem2BgiwiQx-M(aVRY^OkTC_)?R7&WdU z_>es~cCLd{+BAzp2|X$y6XSZ-tVvg50$_+*G`$n;FLU|6!2S|!z;$ApwOJ{5XncSM%pDj zbl&lrs$oohxmG>TR2e>d%6*X#9z8?d?3lkr7k+k*dyYEL0Ww`c^S;=yyYUUv?0jM! z=UlrgwGT0*NysL$u2K2GN<+nU860HpLb!Fh~;LGR6OHBZL2kAmQ|5K7y zB-J63SQL;YwFv3N9Zz|N)6|^@lXXU$71@13`rMR!qrKMl)aHU%i_bHUD`QR_%t>t? zgX+1OW($xNlPQ)1&0zWL44_3ZCJJ!ho@%X23iF+MeX{hQQVz{4xLM zYqncnQ<}w37j?x2NyVT|IX#>$kGy1Sa9=U6OXLmaQ2eW7V^1sQSYRluWh5B%9KR&z z%|kD_B#SpI=!?lu2DAu(cTtXhUl!B*UNPCnjAuQ@WFump)lh(hl8q-Wajc(TK@pA8 z2`^j^O^(I$9>hGE(juk;7<<5LACc?icb6!n&MsktS|Yd$J3ZBuQ}&8*bOKGeiHl8W z{ZqGyBmGyrvQ|LWTlp2ZKZNIuK4?DqHG)rb8tR^2ZL$Y<&*3aK=4KncDJ+2I`Jstj zAp?Un7^v{y{W$CBx9eXbkEJY;9uGlP*H!Oeafc2rzSiL@qM}p_Q!D2~G~MT?KiymP z#OP?mlFXz1v>CL;`h&9Esc$MtBSmc~+rn}MXLEu}`MWON)^lIH0g*Lnlm!mv{X$Rxgf|2)Kh(S{Q<76J%)8si^Mt;Iz~&vKf&X z>McM5!hji)D#p(m?kwOgckNh~(U_QmEldDIS%ofiHPi`6v`y(x5{A*vV8$t- W% z-&`6>xxbQ?EfFYI05t?>6K%o@#QDe_xIP3#bd$!9b;9a|mxxm3(g_#1DZaKM(!}&> zcfO_^L|E)|CgiF=e^nwpoiU=oh8JeqoFLvxJU6wgFA1?<%NAru0u`z5d0!^B0u0KS zWt7=Au#Cd3KRSnTO_}S`(xfv&;8Xx#v_%5goYv)qvDhR0fG{SJ6yJ}S5{u$DEL7f5!P62ajj^)bwSN91BLNB+CoVIhT7qBa%<55p zSFLJ)?rvEPG=thOP2mwhJ~3@Leicd>i&mOPaI&vIdj$!=P4~IGsq6B%3~#)g-rPy) zVyDZBk;YKC6IqJTY`)`%{X~`jo=YP7`YJPT4A$ipI|?w~D0G9&lC?d28t~;Z%N&!cU{#Z%L^$GW-Mhpa zRO^fBsBh{(9sq0wnb|qj3Cf-iXPj3SPrKkoqaqZI?&jN=_j%PiYjVqy(}J$V?w;mwxBi) zA%S2ip~VaINT`YD5dR|JQmv#yTM(Kq8R`I(-ldskg34ZPTG!8w zF>_msJ6nfqfHu$%Q6}G^@QtD-DCAw6u<|%??{=7I_HK5 zg2f;|t1I!qO2P{QQQM*kJXupTEsY_D6)fDCZY0y$%Fnfu8jw`hnR}|R`vK}ZY*{r0 zKW_EVE<*&{&Va7Y5he~;<#{GC@DJ|VqfKLSUf0&#FQwh&Xz&uV#2%zK!7+em7_hS7 z@W5UE;O=((mFd$0z#invxiwz7^4i3|_@zdi1_U^R2^-0Y z4@BL+EpA8a|FLV4?NrZXM2oslVPVLfF#QF9`z5-BV;Wh~im}iAlhTMi*G{I44i`8Q zY%*bn;CownK;xdqFz__nIV%5F#qWxs^^)L}Ty?vHWIGd7-qSkJ#YlU+5FY3u1@&S? z0%eCptVQ}E&I$ZRvwL4fG9=W3F?n@%%jxuxoP*r5HOmC`#`@e$oWaL1R(|q*{Bcz^7z41R)mKt-%TVY9!Ws7lSBd~{GZZd-q6j*ljcldw4gbu0?g0M z-hr`?MM!+-H{FfHnrR}zXGcSP7zSmCeBmu+mQA0S1GXVB&vwicG=CLg2zNslROfQQCa-jMt_9F5`Z==H7V&uIDJ6wK% zre&e>vKS}(>AfCQPX%zySr?X9Ymz`Ya4Bz6V`AwGcBJ@II=O(~h>w z%91$)$66?Sa{2=FmK?cfX$lu(b~B9uPdZ0*z((RoK>lQ{Eie4C&xJI?sPk$=Mo?4a z@N3&58M{x??@~U`DpAf7AG010FMXz&lSij+NX$@{vLMtQ;~crYkgNS5eA5uEla}7w z3XKEl$C6_i>ON(t22nV`>}vx^tMr5f7F}UF=f(VR@OC$^dxF7lIVwhYEE<$!F%ljT zsiU^fj%?zyuU#E8;(aVu!?g5U3v6vioh?(^%~G28ljEDv1AR|IBd&Ve+}J&#ypvLUNYO;hy1OC6jJCFl&C-I#e`f?SVBem5qwYU#lR4NXcIWow)_T-D7dj|5;Xx4lMP zJrUw`ix2va{iIORw(EJrU~8h zjK1K4fdJUjOfNxPfsIG?$u~-P3D1uf644Fp`z^0qZ3SdX$Rv>IZWl3V>lBX1Wf0?8 zWZ7Y#NcE8PeD!p1w@0jBGbyzJ2IT5SzgRAUiu>d55#zm&P>JP zI$n#a1_*GZK8_gY;Q?-FKCf!96N~l7yp`w7vi&6SCHX1s9_g4w>oOK^iD;ClZnGD> z0YA~iSSkXu^%Pj7%a;qd(r|oIH?|gTvByc!-SU(f60sghZZ35Jql*A;H5MOR?h2>+ z4)JF_eGpKbqOlKyQd(1LiATN8vczctKV5KfV3C_}PQiY9qg>^`i zj)f$a5m{RXd^|l&9#IlGZ0+MnJ4x-uEM2zG64!LCy;SLjzK2DD+^K_e74%5wD_4S$B(C=%fZ3Uj@G+blB>9^qIVJ-5sPdmBB~N7Zaw7HlB;Pr^<4 z)K!M2A`9gPk~&?R_-ZEI{D}GFLBbQ-S6)r%yV%F{)0t8(ATAPqrVIMTBtxTxn6daJ z!Pfz-S!RiKuo7%X^Q-2b8F-JRhIr6p4R<-BlHu{bjrd*DHl*(*S$nHz888Ud%mdOf zhg_ojedHR$tiG?Su|eLqsfsP6K*?_86xQTcb_+c()L<~AHdFHDoihky^*C^U+*dcCEf!!at=t$PD+0|V4uzP9A9#GK58 z(K8d5{dO+c3q60T2qowUe6)-fM#C4hF@;v-EL|9xFsMfJLH>kAHFfB>QgI|zg8)oG zwyMrcFOIe4zcv!Es^MY&B^56x;LVTfo>?T({Y7=Vr4y{zqF(z7F)u&UKZhE`Ss0Vs z-JtZ=a+$~0NfXe-V~lsCJWBH%19&jN-o|eIk&+z@MAZsk@pe^`=fV}vRits)^zR}KvP)Cx6=&;2~qF-A_wGD>kR1r$~3~al~6zORFq>lj8aUri2R89lue=zs? z1^&=62k#_SwUn-Ds~y}?rXioa4dP*^zXbrFPjQf2#<8IN9hM6-PCz0fn)(wb6PLZlQ)-7@LD;0Wci1580@WBap0&Nf! zkn^KcLQdu^C|7wmw-lx?`U>sT7?tpUg2-8T?bjGDf(!yPfxr z;Uds>?Bb2ePr?k*d(-iNOY$*`NdjZK_h8()@M4e0M4wEnN$%X8%)G}hxYn_Q`lkR( z-S-Ki|33hAK#IT4?5Tz34>v#=<4w_;P%Dg)Mrg+(JN%qGVEg5SV+99{A+E&!Kwx)< z*kUXUGr-S0rTc#>9*~Fw!5+18V1O|mU~`sPx;55oBeZ^&DOp>=^8sgCo>*=pz#kCi z5JAs@gc0Iv$BGy$;se;7b4}F?Z*OWr_#{ldXJDP_BLyEcf0p#n6zEq%_d}%nDgoC5 z@~Ee{d z?=n4L{(KxS{87_UB8TXE_yQ&ze0L1+`wocjEr8rW;Y>5K-hz7$>jLOYDas*&9Kp_o z*8F=-ug~GEWO|<=+w<%w4jGjfSU1xWMfKN3GkR#DMc#(^`$8YgW@uYNW0W(|jKl`u z2VhRRUy&!TPcDrQwmN4T!wGM}3ge&?Gmq>$V?YQ8B4?Y*>O&-rKf-jDnWX+B^tqO` z*apBk22d}A`@3M7!bsPq;oC5CkBN1q&CUX{QlgmnR6fnFOxj^N%ls%CJh*qOwH2Qr$V?F;Qt*<5Vf!mY8L zx5Cf31L^27aXwcNmmyWDt6YnBn;5;0AS%@ zW+S}jqV|hGp7{J)SPM|=LdX|Z%(D{Xy21PDeTLF8AmJ=CdG?p^_Le|?7;*)$R)qY} zqup)s^GxN4U_61b?+0w}JD~EI#v}%UXWxsLH-UWBg!by_*z)GYCn(AtP$mx8^rVx; zBa;^chb5e420d#};aDIq$e6?o@cct>VyGL#@8j4vOztAQ=M-%7!Av{ez9L1>HkGGG ztpI;S8i$CNA9{+P^<&GLqtr2GXm&qc9CPTS6*wliQqt;Q$ABmfl!E~o@c^51Etl2R z3U)4Ph(g^pNni4PsZ`w>!Ur%HvIETB>4ildF3qj7D(a{333U+ z?!(&fan%oG9SHHj)BPR)HDADl1BEdl&y{H&5z)&xuVnKQ+zgAE`97wMli()_)he(-cn*x6b z?7*RHTT(|{o9@+P=7u6RB@?c<}#2ldl zJ!s@O+Z6Hvu-6po1aU)jrS5A0pHUWVO`TsOgF7HEue?5eh$Qnz{yhwc;y@`FsAmrv zHfNhcjUaJ^zHqNRQ5@hkdy20SsTV0dmd9mNg-qF*fI1v+GGGCy(sE1OtLN!216ti9^JOS1wQQ2~j&B z)`NyckJ_snoygf%K29nm769GD02?t1p=+hh(XPdf(fnZsWbg5`pEOaTrvcix)Q<3n|1}K!gZvRCjc@#Y zEb->G`%^@H!S}_0Bpw+X4A8maYo#s7o}vp-Hz=ZNhrgCPi^T(?J!ozyf&n&s(*HgT zz<5F9V8M8!We3yQ^G9U%DV7iqu*Sd|DHsr|89MRmg;YGqc+Rz)=UU2(0ht`4e_z*( z<3&wpbfKi}IT{B(J#~E!!5%Y-7?5HIM0(OG_bfAcdQ&Q?50RWbX#On@kut{Xqg_zx zlIE*v95l00Z@4ST;#qat_r`z(JHQ4566!@l{mA}d3_ScV#sG}>C*06fZ|l)C4pdY) zoQ$a^n~r5Skdr@Bk6u(_@c?`Nh@}3c_3A&OU{AV#i$hf3@kezh^r))iOGg_mD;fvz zX zjlLfS0XBVz>U*Z4^mDG6d1TCWW7$1w|I^2Z6F;KOY0mkAI%mRx8a5TF!DcJYVk{_w zKO*g%>+f@}<#*x`{fBY~Wm)j>TtV-y^gUg9KesMftv2N#n!c_S1Q<}mH`8D z^&wI+he(ng_`ie!$Q@#T@ZPhxSr<`#OTq#Cw06|AtHrAV_m)m47K!P(zP@kz)u@TO{eP2Mz_wbru&o1&moe80nzjBzXt|n z@yh?3IYjbfz-qK4J_kG0i9i4*Pm1&JHX~_Qx<0$%Ec@H|A<4x ztp85v-ElYc=fTdevm%?snHi~TvC*wOIG}$lO}P4@UM3L-b8K(l*yxBZ7d4^w9?HQV zsV@e;KacFcCmw*j&`Z1?ggh~}1Fv^vxh(J6#-O=8I#mP*f_U)vF!ITtV_k7>_1n8Jh9t=o`2mZ$)lB#>``{D0bXE(Gxlosv~-o{DSP7z(!2M2;bF7}#@#|<#@ z$(qq1uO`R7_F`evr&#~r*z!lD$Ak>$+x{QM!1Dv0U*F%=;nI#wmy*RHb|HP;j9Z%+ zaOlzAt#joq`D4T5(pY?SG!SZQT36U%WgJJ-u~K z_tL!5XV0Gf8U3=MUZQvBW!ECUJpc2h_q%!3rJqZyR@}4pI8t)(_U3n0zwT@pxwm0u z^`4#6&V>X|3hz7b($a{O#4?o>RXdKmmT{|x$f&8Ut!~f+pHK7qd7X8-c&H^u_0_BK z>sOSo>h!GiXij0UxleEJr%qXwE+?}eFAf~MqxGt8*Y<=yyMD2x$;%FD)sg=9Z|}Mj zobNW{@|rc{PJQ}o@1Iq=o2tJ>wYZVCzfbVB7q`w|-~aN@&r5GM4(~MWZqVTJ+aVn^ zycdkJ;Ka4vTyxF3U^zGU;FhY0KT;}f+iUmR^-}+uNA2K*QE`FsadBJP=#4(}^lXmq z@liobt2=x+>(ctn{&$Ch&lGm=+IZHl^XC28VKC>A z_BGb)m<(*M`2fU!JwTI_uW^IiqY> z`NwO{wuwu;{iE(%M?2dUtBQ|=-m`Xyf4zHzpLf4|D+VOQxwTq6TJ!n#mAWrq#y-Bd z3q85(7xTX1-S7M6(xJ`Q8q1g&Z`-3Au`fgJd@>rzxjH#$)q!fiKOU}W z6m&A9Lzm>Y8!UtNtZ)qN5LQvN+QhzKhuh-7!g=2=e;Y9P+pFp>DD^HXJbSc#Md_}p zW?FIA4HnvdE^+G9{msnk$76QAQF$C$v(KPipQaPGwj6HgXt!$9L)YR7>c`FUPbHMy zT;|?q)|`kl*>igPx|DqAHC59qfRmpbWSIZuSoPmy&wYD^UL7A-=HnLnaf_+@Z|lFz zN{#(w`}Nf44e!7G@O=B6PGh4+nHOGcSTts*chJ+oRet8)#^dJAbsTctbfkTJS?1yw zaaXM;HIDx9=@R;~edNbG{gn$gRxj6&ztSCZjej4BOlvdA5C9@U9SA%*(u}lz2>7eT}onGOxsl-<&OiF(dgf8j_U5 z<~SsAbGp4s`&K@!G%aiIc|@E#x}&G+wLatZ4jyv7<@tR2%}=ery$ss)_tPPF4FzRYg(Yz?6e)RvvrUWbEfJ(ceB)f1Nn(?}*=S-zjxb-Rxa4z5MT-yI->6 z=dJnF`f#F8^S^ITb;*9X{I9+NaY?Rr);X)W%dT?X*bLY@r1vlRmFvb#8SL)p`b%=j zgVOS{Re6ES9b8Ou2YENIs;!#b-+j2|D(;eJ?ykqpO-BCt@Z357d!K(=bMx<;cV}LsikH@m zZsu(>+&`z!JwCq6RyV(2mNuPK)VY`U?soe(mO2(ZD|ouYG3}|7_Qu^sy6@g?&iuS= zyvgPe7>UblSVF)oeRRspY5LD_pZHml;6+{ZupUvO;wr>+T2ssyDgSv zj^5Op+g>ij&q;TiTUFbh>Y( z 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 uZKCaZw8MZg|)!q+l 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%0AT#XkpzsROgSOD;u;( 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 Path: + # create a non managed raw study version 840 + study_dir = tmp_path / "ext_workspace" / "ext-840" + study_dir.mkdir(exist_ok=True) + zip_path = ASSETS_DIR.joinpath("ext-840.zip") + with zipfile.ZipFile(zip_path) as zip_output: + zip_output.extractall(path=study_dir) + + # create a non managed raw study version 850 + study_dir = tmp_path / "ext_workspace" / "ext-850" + study_dir.mkdir(exist_ok=True) + zip_path = ASSETS_DIR.joinpath("ext-850.zip") + with zipfile.ZipFile(zip_path) as zip_output: + zip_output.extractall(path=study_dir) + + # create a non managed raw study version 860 + study_dir = tmp_path / "ext_workspace" / "ext-860" + study_dir.mkdir(exist_ok=True) + zip_path = ASSETS_DIR.joinpath("ext-860.zip") + with zipfile.ZipFile(zip_path) as zip_output: + zip_output.extractall(path=study_dir) + + # create a non managed raw study version 840 to be deleted from disk + study_dir = tmp_path / "ext_workspace" / "to-be-deleted-840" + study_dir.mkdir(exist_ok=True) + zip_path = ASSETS_DIR.joinpath("ext-840.zip") + with zipfile.ZipFile(zip_path) as zip_output: + zip_output.extractall(path=study_dir) + + return study_dir + + def test_study_listing( + self, + client: TestClient, + admin_access_token: str, + to_be_deleted_study_path: Path, + ): + """ + This test verifies that database is correctly initialized and then runs the filtering tests with different + parameters + """ + + # database update to include non managed studies + res = client.post( + "/v1/watcher/_scan", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"path": "ext"}, + ) + res.raise_for_status() + task_id = res.json() + task = wait_task_completion(client, admin_access_token, task_id) + assert task.status == TaskStatus.COMPLETED, task + + accept_status_codes = {200, 201} + studies_url = "/v1/studies" + + # retrieve a created non managed + to be deleted studies ids + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + folder_map = {v["folder"]: k for k, v in res.json().items()} + non_managed_840_id = folder_map["ext-840"] + non_managed_850_id = folder_map["ext-850"] + non_managed_860_id = folder_map["ext-860"] + to_be_deleted_id = folder_map["to-be-deleted-840"] + + # delete study `to_be_deleted_id` from disk + shutil.rmtree(to_be_deleted_study_path) + assert not to_be_deleted_study_path.exists() + + # database update with missing studies + res = client.post( + "/v1/watcher/_scan", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"path": "ext"}, + ) + res.raise_for_status() + task_id = res.json() + task = wait_task_completion(client, admin_access_token, task_id) + assert task.status == TaskStatus.COMPLETED, task + + # change permissions for non managed studies (no access but to admin) + non_managed_studies = {non_managed_840_id, non_managed_850_id, non_managed_860_id, to_be_deleted_id} + no_access_code = "NONE" + for non_managed_study in non_managed_studies: + res = client.put( + f"{studies_url}/{non_managed_study}/public_mode/{no_access_code}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": "James Bond", "password": "0007"}, + ) + assert res.status_code in accept_status_codes, res.json() + + # create a user 'James Bond' with password '007' + res = client.post( + "/v1/users", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": "James Bond", "password": "0007"}, + ) + assert res.status_code in accept_status_codes, res.json() + james_bond_id = res.json().get("id") + + # create a user 'John Doe' with password '0011' + res = client.post( + "/v1/users", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": "John Doe", "password": "0011"}, + ) + assert res.status_code in accept_status_codes, res.json() + john_doe_id = res.json().get("id") + + # create a group 'Group X' with id 'groupX' + res = client.post( + "/v1/groups", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": "Group X", "id": "groupX"}, + ) + assert res.status_code in accept_status_codes, res.json() + group_x_id = res.json().get("id") + assert group_x_id == "groupX" + + # create a group 'Group Y' with id 'groupY' + res = client.post( + "/v1/groups", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": "Group Y", "id": "groupY"}, + ) + assert res.status_code in accept_status_codes, res.json() + group_y_id = res.json().get("id") + assert group_y_id == "groupY" + + # login 'James Bond' + res = client.post( + "/v1/login", + json={"username": "James Bond", "password": "0007"}, + ) + assert res.status_code in accept_status_codes, res.json() + assert res.json().get("user") == james_bond_id + james_bond_access_token = res.json().get("access_token") + + # login 'John Doe' + res = client.post( + "/v1/login", + json={"username": "John Doe", "password": "0011"}, + ) + assert res.status_code in accept_status_codes, res.json() + assert res.json().get("user") == john_doe_id + john_doe_access_token = res.json().get("access_token") + + # create a bot user 'James Bond' + res = client.post( + "/v1/bots", + headers={"Authorization": f"Bearer {james_bond_access_token}"}, + json={"name": "James Bond", "roles": []}, + ) + assert res.status_code in accept_status_codes, res.json() + james_bond_bot_token = res.json() + + # create a raw study version 840 + res = client.post( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "raw-840", "version": "840"}, + ) + assert res.status_code in accept_status_codes, res.json() + raw_840_id = res.json() + + # create a raw study version 850 + res = client.post( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "raw-850", "version": "850"}, + ) + assert res.status_code in accept_status_codes, res.json() + raw_850_id = res.json() + + # create a raw study version 860 + res = client.post( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "raw-860", "version": "860"}, + ) + assert res.status_code in accept_status_codes, res.json() + raw_860_id = res.json() + + # create a variant study version 840 + res = client.post( + f"{studies_url}/{raw_840_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "variant-840", "version": "840"}, + ) + assert res.status_code in accept_status_codes, res.json() + variant_840_id = res.json() + + # create a variant study version 850 + res = client.post( + f"{studies_url}/{raw_850_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "variant-850", "version": "850"}, + ) + assert res.status_code in accept_status_codes, res.json() + variant_850_id = res.json() + + # create a variant study version 860 + res = client.post( + f"{studies_url}/{raw_860_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "variant-860", "version": "860"}, + ) + assert res.status_code in accept_status_codes, res.json() + variant_860_id = res.json() + + # create a raw study version 840 to be archived + res = client.post( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "archived-raw-840", "version": "840"}, + ) + assert res.status_code in accept_status_codes, res.json() + archived_raw_840_id = res.json() + + # create a raw study version 850 to be archived + res = client.post( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "archived-raw-850", "version": "850"}, + ) + assert res.status_code in accept_status_codes, res.json() + archived_raw_850_id = res.json() + + # create a variant study version 840 + res = client.post( + f"{studies_url}/{archived_raw_840_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "archived-variant-840", "version": "840"}, + ) + assert res.status_code in accept_status_codes, res.json() + archived_variant_840_id = res.json() + + # create a variant study version 850 to be archived + res = client.post( + f"{studies_url}/{archived_raw_850_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "archived-variant-850", "version": "850"}, + ) + assert res.status_code in accept_status_codes, res.json() + archived_variant_850_id = res.json() + + # create a raw study to be transfered in folder1 + zip_path = ASSETS_DIR / "STA-mini.zip" + res = client.post( + f"{studies_url}/_import", + headers={"Authorization": f"Bearer {admin_access_token}"}, + files={"study": io.BytesIO(zip_path.read_bytes())}, + ) + assert res.status_code in accept_status_codes, res.json() + folder1_study_id = res.json() + res = client.put( + f"{studies_url}/{folder1_study_id}/move", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"folder_dest": "folder1"}, + ) + assert res.status_code in accept_status_codes, res.json() + + # give permission to James Bond for some select studies + james_bond_studies: set = {raw_840_id, variant_850_id, non_managed_860_id} + for james_bond_study in james_bond_studies: + res = client.put( + f"{studies_url}/{james_bond_study}/owner/{james_bond_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + + # associate select studies to each group: groupX, groupY + group_x_studies: set = {variant_850_id, raw_860_id} + group_y_studies: set = {raw_850_id, raw_860_id} + for group_x_study in group_x_studies: + res = client.put( + f"{studies_url}/{group_x_study}/groups/{group_x_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + for group_y_study in group_y_studies: + res = client.put( + f"{studies_url}/{group_y_study}/groups/{group_y_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + + # archive studies + archive_studies = {archived_raw_840_id, archived_raw_850_id} + for archive_study in archive_studies: + res = client.put( + f"{studies_url}/{archive_study}/archive", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + archiving_study_task_id = res.json() + task = wait_task_completion(client, admin_access_token, archiving_study_task_id) + assert task.status == TaskStatus.COMPLETED, task + + # the testing studies set + all_studies: set = { + raw_840_id, + raw_850_id, + raw_860_id, + non_managed_840_id, + non_managed_850_id, + non_managed_860_id, + variant_840_id, + variant_850_id, + variant_860_id, + archived_raw_840_id, + archived_raw_850_id, + archived_variant_840_id, + archived_variant_850_id, + folder1_study_id, + to_be_deleted_id, + } + + pm = itemgetter("public_mode") + + # tests (1) for user permission filtering + # test 1.a for a user with no access permission + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {john_doe_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.intersection(study_map) + assert all(map(lambda x: pm(x) in [PublicMode.READ, PublicMode.FULL], study_map.values())) + # test 1.b for an admin user + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(study_map) + # test 1.c for a user with access to select studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {james_bond_access_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not james_bond_studies.difference(study_map) + assert all( + map( + lambda x: pm(x) in [PublicMode.READ, PublicMode.FULL], + [e for k, e in study_map.items() if k not in james_bond_studies], + ) + ) + # #TODO you need to update the permission for James Bond bot + # test 1.d for a user bot with access to select studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {james_bond_bot_token}"}, + ) + assert res.status_code in accept_status_codes, res.json() + # #TODO add the correct test assertions + # study_map: t.Dict[str, t.Any] = res.json() + # assert not set(james_bond_studies).difference(study_map) + # assert all( + # map( + # lambda x: pm(x) in [PublicMode.READ, PublicMode.FULL], + # [e for k, e in study_map.items() if k not in james_bond_studies], + # ) + # ) + + # tests (2) for studies names filtering + # test 2.a with matching studies + res = client.get(studies_url, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "840"}) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert all(map(lambda x: "840" in x.get("name"), study_map.values())) and len(study_map) >= 5 + # test 2.b with no matching studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "NON-SENSE-746846351469798465"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not study_map + + # tests (3) managed studies vs non managed + # test 3.a managed + managed_studies = all_studies.difference(non_managed_studies) + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"managed": True}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not managed_studies.difference(study_map) + assert not all_studies.difference(managed_studies).intersection(study_map) + # test 3.b non managed + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"managed": False}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(managed_studies).difference(study_map) + assert not managed_studies.intersection(study_map) + + # tests (4) archived vs non archived + # test 4.a archived studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"archived": True}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not archive_studies.difference(study_map) + assert not all_studies.difference(archive_studies).intersection(study_map) + # test 4.b non archived + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"archived": False}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(archive_studies).difference(study_map) + assert not archive_studies.intersection(study_map) + + # tests (5) for filtering variant studies + variant_studies = { + variant_840_id, + variant_850_id, + variant_860_id, + archived_variant_840_id, + archived_variant_850_id, + } + # test 5.a get variant studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"variant": True}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not variant_studies.difference(study_map) + assert not all_studies.difference(variant_studies).intersection(study_map) + # test 5.b get raw studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"variant": False}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(variant_studies).difference(study_map) + assert not variant_studies.intersection(study_map) + + # tests (6) for version filtering + studies_version_850: set = { + raw_850_id, + non_managed_850_id, + variant_850_id, + archived_raw_850_id, + archived_variant_850_id, + } + studies_version_860: set = { + raw_860_id, + non_managed_860_id, + variant_860_id, + } + # test 6.a filter for one version: 860 + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"versions": "860"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(studies_version_860).intersection(study_map) + assert not studies_version_860.difference(study_map) + # test 8.b filter for two versions: 850, 860 + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"versions": "850,860"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(studies_version_850.union(studies_version_860)).intersection(study_map) + assert not studies_version_850.union(studies_version_860).difference(study_map) + + # tests (7) for users filtering + # test 7.a to get studies for one user: James Bond + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"users": f"{james_bond_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(james_bond_studies).intersection(study_map) + assert not james_bond_studies.difference(study_map) + # test 7.b to get studies for two users + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"users": f"{james_bond_id},{john_doe_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(james_bond_studies).intersection(study_map) + assert not james_bond_studies.difference(study_map) + + # tests (8) for groups filtering + # test 8.a filter for one group: groupX + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"groups": f"{group_x_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(group_x_studies).intersection(study_map) + assert not group_x_studies.difference(study_map) + # test 8.b filter for two groups: groupX, groupY + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"groups": f"{group_x_id},{group_y_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(group_x_studies.union(group_y_studies)).intersection(study_map) + assert not group_x_studies.union(group_y_studies).difference(study_map) + + # TODO you need to add filtering through tags to the search engine + # tests (9) for tags filtering + # test 9.a filtering for one tag: decennial + # test 9.b filtering for two tags: decennial,winter_transition + + # tests (10) for studies uuids sequence filtering + # test 10.a filter for one uuid + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"studiesIds": f"{raw_840_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert {raw_840_id} == set(study_map) + # test 10.b filter for two uuids + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"studiesIds": f"{raw_840_id},{raw_860_id}"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert {raw_840_id, raw_860_id} == set(study_map) + + # tests (11) studies filtering regarding existence on disk + existing_studies = all_studies.difference({to_be_deleted_id}) + # test 11.a filter existing studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"exists": True}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not existing_studies.difference(study_map) + assert not all_studies.difference(existing_studies).intersection(study_map) + # test 11.b filter non-existing studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"exists": False}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not all_studies.difference(existing_studies).difference(study_map) + assert not existing_studies.intersection(study_map) + + # tests (12) studies filtering with workspace + ext_workspace_studies = non_managed_studies + # test 12.a filter `ext` workspace studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"workspace": "ext"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not ext_workspace_studies.difference(study_map) + assert not all_studies.difference(ext_workspace_studies).intersection(study_map) + + # tests (13) studies filtering with folder + # test 13.a filter `folder1` studies + res = client.get( + studies_url, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"folder": "folder1"}, + ) + assert res.status_code in accept_status_codes, res.json() + study_map: t.Dict[str, t.Any] = res.json() + assert not {folder1_study_id}.difference(study_map) + assert not all_studies.difference({folder1_study_id}).intersection(study_map) From cf73f44b7d5aeecdeca543a1ad50e335ecd763bd Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Tue, 23 Jan 2024 16:03:37 +0100 Subject: [PATCH 05/13] docs(study-search): correct docstring in `StudyFilter` and `StudyPagination` --- antarest/study/repository.py | 41 +++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 1ba4bb62eb..a8780ac381 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -1,17 +1,17 @@ import datetime +import enum import logging import typing as t -from enum import Enum from pydantic import BaseModel, Field -from sqlalchemy import String, and_, any_, not_, or_ # type: ignore +from sqlalchemy import not_, or_ # type: ignore from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import CacheConstants, ICache from antarest.core.utils.fastapi_sqlalchemy import db from antarest.login.model import Group from antarest.study.common.utils import get_study_information -from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData, groups_metadata +from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData logger = logging.getLogger(__name__) @@ -41,19 +41,19 @@ def escape_like(string: str, escape_char: str = "\\") -> str: class StudyFilter(BaseModel, frozen=True): """Study filter class gathering the main filtering parameters - Attrs: - - name: optional name regex of the study to match - - managed: indicate if just managed studies should be retrieved - - archived: optional if the study is archived - - variant: optional if the study is raw study - - versions: versions to filter by - - users: users to filter by - - groups: groups to filter by - - tags: tags to filter by - - studies_ids: optional list of ids to be matched, **note that if empty the query result will be empty also** - - exists: if raw study missing - - workspace: optional workspace of the study - - folder: optional folder prefix of the study + Attributes: + name: optional name regex of the study to match + managed: indicate if just managed studies should be retrieved + archived: optional if the study is archived + variant: optional if the study is raw study + versions: versions to filter by + users: users to filter by + groups: groups to filter by + tags: tags to filter by + studies_ids: studies ids to filter by + exists: if raw study missing + workspace: optional workspace of the study + folder: optional folder prefix of the study """ name: str = "" @@ -70,7 +70,7 @@ class StudyFilter(BaseModel, frozen=True): folder: str = "" -class StudySortBy(str, Enum): +class StudySortBy(str, enum.Enum): """How to sort the results of studies query results""" NO_SORT = "" @@ -83,8 +83,10 @@ class StudySortBy(str, Enum): class StudyPagination(BaseModel, frozen=True): """ Pagination of a studies query results - page_nb: offset - page_size: SQL limit + + Attributes: + page_nb: offset + page_size: SQL limit """ page_nb: int = 0 @@ -201,6 +203,7 @@ def get_all( # efficiently (see: `utils.get_study_information`) entity = with_polymorphic(Study, "*") + # noinspection PyTypeChecker q = self.session.query(entity) if study_filter.exists is not None: if study_filter.exists: From 0fe94b8a728856a871e31f69275bf0e372c43acf Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 24 Jan 2024 23:05:47 +0100 Subject: [PATCH 06/13] fix(study-search): correct get_studies endpoint and add unit tests with error checking --- antarest/launcher/service.py | 6 +- antarest/study/repository.py | 31 +- antarest/study/web/studies_blueprint.py | 167 ++++---- .../studies_blueprint/test_get_studies.py | 395 ++++++++++++------ tests/study/test_repository.py | 110 +++-- 5 files changed, 418 insertions(+), 291 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 8fd80bd09f..86b65ec9ce 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -306,11 +306,11 @@ def _filter_from_user_permission(self, job_results: List[JobResult], user: Optio orphan_visibility_threshold = datetime.utcnow() - timedelta(days=ORPHAN_JOBS_VISIBILITY_THRESHOLD) allowed_job_results = [] - studies_ids = [job_result.study_id for job_result in job_results] - if studies_ids: + study_ids = [job_result.study_id for job_result in job_results] + if study_ids: studies = { study.id: study - for study in self.study_service.repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) + for study in self.study_service.repository.get_all(study_filter=StudyFilter(study_ids=study_ids)) } else: studies = {} diff --git a/antarest/study/repository.py b/antarest/study/repository.py index a8780ac381..47a33e5d7f 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -3,8 +3,8 @@ import logging import typing as t -from pydantic import BaseModel, Field -from sqlalchemy import not_, or_ # type: ignore +from pydantic import BaseModel, NonNegativeInt +from sqlalchemy import func, not_, or_ # type: ignore from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import CacheConstants, ICache @@ -20,9 +20,9 @@ def escape_like(string: str, escape_char: str = "\\") -> str: """ Escape the string parameter used in SQL LIKE expressions. - Examples: - from sqlalchemy_utils import escape_like + Examples:: + from sqlalchemy_utils import escape_like query = session.query(User).filter( User.name.ilike(escape_like('John')) @@ -38,7 +38,7 @@ def escape_like(string: str, escape_char: str = "\\") -> str: return string.replace(escape_char, escape_char * 2).replace("%", escape_char + "%").replace("_", escape_char + "_") -class StudyFilter(BaseModel, frozen=True): +class StudyFilter(BaseModel, frozen=True, extra="forbid"): """Study filter class gathering the main filtering parameters Attributes: @@ -50,7 +50,7 @@ class StudyFilter(BaseModel, frozen=True): users: users to filter by groups: groups to filter by tags: tags to filter by - studies_ids: studies ids to filter by + study_ids: studies ids to filter by exists: if raw study missing workspace: optional workspace of the study folder: optional folder prefix of the study @@ -61,10 +61,10 @@ class StudyFilter(BaseModel, frozen=True): archived: t.Optional[bool] = None variant: t.Optional[bool] = None versions: t.Sequence[str] = () - users: t.Sequence["int"] = () + users: t.Sequence[int] = () groups: t.Sequence[str] = () tags: t.Sequence[str] = () - studies_ids: t.Sequence[str] = () + study_ids: t.Sequence[str] = () exists: t.Optional[bool] = None workspace: str = "" folder: str = "" @@ -80,7 +80,7 @@ class StudySortBy(str, enum.Enum): DATE_DESC = "-date" -class StudyPagination(BaseModel, frozen=True): +class StudyPagination(BaseModel, frozen=True, extra="forbid"): """ Pagination of a studies query results @@ -89,8 +89,8 @@ class StudyPagination(BaseModel, frozen=True): page_size: SQL limit """ - page_nb: int = 0 - page_size: int = Field(0, ge=0) + page_nb: NonNegativeInt = 0 + page_size: NonNegativeInt = 0 class StudyMetadataRepository: @@ -219,8 +219,8 @@ def get_all( else: q = q.filter(entity.type == "rawstudy") q = q.filter(RawStudy.workspace != DEFAULT_WORKSPACE_NAME) - if study_filter.studies_ids: - q = q.filter(entity.id.in_(study_filter.studies_ids)) + if study_filter.study_ids: + q = q.filter(entity.id.in_(study_filter.study_ids)) if study_filter.users: q = q.filter(entity.owner_id.in_(study_filter.users)) if study_filter.groups: @@ -243,16 +243,15 @@ def get_all( if study_filter.versions: q = q.filter(entity.version.in_(study_filter.versions)) - # sorting if sort_by != StudySortBy.NO_SORT: if sort_by == StudySortBy.DATE_DESC: q = q.order_by(entity.created_at.desc()) elif sort_by == StudySortBy.DATE_ASC: q = q.order_by(entity.created_at.asc()) elif sort_by == StudySortBy.NAME_DESC: - q = q.order_by(entity.name.desc()) + q = q.order_by(func.upper(entity.name).desc()) elif sort_by == StudySortBy.NAME_ASC: - q = q.order_by(entity.name.asc()) + q = q.order_by(func.upper(entity.name).asc()) else: raise NotImplementedError(sort_by) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 09825f6761..8056d23e2e 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -1,11 +1,14 @@ +import collections import io import logging +import re from http import HTTPStatus from pathlib import Path from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, File, HTTPException, Query, Request from markupsafe import escape +from pydantic import NonNegativeInt from antarest.core.config import Config from antarest.core.exceptions import BadZipBinary @@ -33,7 +36,12 @@ logger = logging.getLogger(__name__) -SEQUENCE_SEPARATOR = "," +def _split_comma_separated_values(value: str, *, default: Sequence[str] = ()) -> Sequence[str]: + """Split a comma-separated list of values into an ordered set of strings.""" + value = value.strip() + value_list = re.split(r"\s*,\s*", value) if value else default + # remove duplicates and preserve order (to have a deterministic result for unit tests). + return list(collections.OrderedDict.fromkeys(value_list)) def create_study_routes(study_service: StudyService, ftm: FileTransferManager, config: Config) -> APIRouter: @@ -54,100 +62,94 @@ def create_study_routes(study_service: StudyService, ftm: FileTransferManager, c "/studies", tags=[APITag.study_management], summary="Get Studies", - response_model=Dict[str, StudyMetadataDTO], ) def get_studies( current_user: JWTUser = Depends(auth.get_current_user), - sort_by: str = Query( - "", - description="- `sort_by`: Sort studies based on their name or date." - " - ``: No sorting to be done." - " - `+name`: Sort by name in ascending order (case-insensitive)." - " - `-name`: Sort by name in descending order (case-insensitive)." - " - `+date`: Sort by creation date in ascending order." - " - `-date`: Sort by creation date in descending order.", - alias="sortBy", - ), - page_nb: int = Query(0, description="Page number (starting from 0).", alias="pageNb"), - page_size: int = Query(0, description="Number of studies per page.", alias="pageSize"), name: str = Query( "", - description="Filter studies based on their name." - "Case-insensitive search for studies whose name starts with the specified value.", + description=( + "Filter studies based on their name." + "Case-insensitive search for studies whose name contains the specified value." + ), alias="name", ), - managed: Optional[bool] = Query( - None, description="Filter studies based on their management status.", alias="managed" - ), - archived: Optional[bool] = Query( - None, description="Filter studies based on their archive status.", alias="archived" - ), - variant: Optional[bool] = Query( - None, description="Filter studies based on their variant status.", alias="variant" - ), - versions: str = Query( - "", - description="Filter studies based on their version(s)." - " Provide a comma-separated list of versions for filtering.", - alias="versions", - ), - users: str = Query( - "", - description="Filter studies based on user(s)." - " Provide a comma-separated list of group IDs for filtering.", - alias="users", - ), - groups: str = Query( - "", - description="Filter studies based on group(s)." - " Provide a comma-separated list of group IDs for filtering.", - alias="groups", - ), - tags: str = Query( - "", - description="Filter studies based on tag(s)." " Provide a comma-separated list of tags for filtering.", - alias="tags", - ), - studies_ids: str = Query( + managed: Optional[bool] = Query(None, description="Filter studies based on their management status."), + archived: Optional[bool] = Query(None, description="Filter studies based on their archive status."), + variant: Optional[bool] = Query(None, description="Filter studies based on their variant status."), + versions: str = Query("", description="Comma-separated list of versions for filtering."), + users: str = Query("", description="Comma-separated list of group IDs for filtering."), + groups: str = Query("", description="Comma-separated list of group IDs for filtering."), + tags: str = Query("", description="Comma-separated list of tags for filtering."), + study_ids: str = Query( "", - description="Filter studies based on their ID(s)." - " Provide a comma-separated list of study IDs for filtering.", - alias="studiesIds", + description="Comma-separated list of study IDs for filtering.", + alias="studyIds", ), - exists: Optional[bool] = Query( - None, - description="Filter studies based on their existence on disk." - " - not set: No specific filtering." - " - `True`: Filter for studies existing on disk." - " - `False`: Filter for studies not existing on disk.", - alias="exists", + exists: Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), + workspace: str = Query("", description="Filter studies based on their workspace."), + folder: str = Query("", description="Filter studies based on their folder."), + sort_by: StudySortBy = Query( + StudySortBy.NO_SORT, + description="Sort studies based on their name (case-insensitive) or date.", + alias="sortBy", ), - workspace: str = Query( - "", - description="Filter studies based on their workspace." - " Search for studies whose workspace matches the specified value.", - alias="workspace", + page_nb: NonNegativeInt = Query( + 0, + description="Page number (starting from 0).", + alias="pageNb", ), - folder: str = Query( - "", - description="Filter studies based on their folder." - " Search for studies whose folder starts with the specified value.", - alias="folder", + page_size: NonNegativeInt = Query( + 0, + description="Number of studies per page (0 = no limit).", + alias="pageSize", ), ) -> Dict[str, StudyMetadataDTO]: + """ + Get the list of studies matching the specified criteria. + + Args: + - `name`: Filter studies based on their name. Case-insensitive search for studies + whose name contains the specified value. + - `managed`: Filter studies based on their management status. + - `archived`: Filter studies based on their archive status. + - `variant`: Filter studies based on their variant status. + - `versions`: Comma-separated list of versions for filtering. + - `users`: Comma-separated list of group IDs for filtering. + - `groups`: Comma-separated list of group IDs for filtering. + - `tags`: Comma-separated list of tags for filtering. + - `studyIds`: Comma-separated list of study IDs for filtering. + - `exists`: Filter studies based on their existence on disk. + - `workspace`: Filter studies based on their workspace. + - `folder`: Filter studies based on their folder. + - `sortBy`: Sort studies based on their name (case-insensitive) or date. + - `pageNb`: Page number (starting from 0). + - `pageSize`: Number of studies per page (0 = no limit). + + Returns: + - A dictionary of studies matching the specified criteria, + where keys are study IDs and values are study properties. + """ + logger.info("Fetching for matching studies", extra={"user": current_user.id}) params = RequestParameters(user=current_user) - study_filter: StudyFilter = StudyFilter( + # todo: there must be another way to do this + # for instance by using a pydantic model with a custom validator + try: + user_list = [int(v) for v in _split_comma_separated_values(users)] + except ValueError: + raise HTTPException(status_code=422, detail="'users' must be a list of integers") from None + + study_filter = StudyFilter( name=name, managed=managed, archived=archived, variant=variant, - versions=versions.split(SEQUENCE_SEPARATOR) if versions else (), - users=users.split(SEQUENCE_SEPARATOR) if users else (), - groups=groups.split(SEQUENCE_SEPARATOR) if groups else (), - tags=tags.split(SEQUENCE_SEPARATOR) if tags else (), - studies_ids=studies_ids.split(SEQUENCE_SEPARATOR) if studies_ids else (), + versions=_split_comma_separated_values(versions), + users=user_list, + groups=_split_comma_separated_values(groups), + tags=_split_comma_separated_values(tags), + study_ids=_split_comma_separated_values(study_ids), exists=exists, workspace=workspace, folder=folder, @@ -156,9 +158,10 @@ def get_studies( matching_studies = study_service.get_studies_information( params=params, study_filter=study_filter, - sort_by=StudySortBy(sort_by.lower()), + sort_by=StudySortBy(sort_by), pagination=StudyPagination(page_nb=page_nb, page_size=page_size), ) + return matching_studies @bp.get( @@ -227,8 +230,8 @@ def import_study( zip_binary = io.BytesIO(study) params = RequestParameters(user=current_user) - group_ids = groups.split(",") if groups else [group.id for group in current_user.groups] - group_ids = [sanitize_uuid(gid) for gid in set(group_ids)] # sanitize and avoid duplicates + group_ids = _split_comma_separated_values(groups, default=[group.id for group in current_user.groups]) + group_ids = [sanitize_uuid(gid) for gid in group_ids] try: uuid = study_service.import_study(zip_binary, group_ids, params) @@ -306,8 +309,8 @@ def copy_study( extra={"user": current_user.id}, ) source_uuid = uuid - group_ids = groups.split(",") if groups else [group.id for group in current_user.groups] - group_ids = [sanitize_uuid(gid) for gid in set(group_ids)] # sanitize and avoid duplicates + group_ids = _split_comma_separated_values(groups, default=[group.id for group in current_user.groups]) + group_ids = [sanitize_uuid(gid) for gid in group_ids] source_uuid_sanitized = sanitize_uuid(source_uuid) destination_name_sanitized = escape(dest) @@ -356,8 +359,8 @@ def create_study( ) -> Any: logger.info(f"Creating new study '{name}'", extra={"user": current_user.id}) name_sanitized = escape(name) - group_ids = groups.split(",") if groups else [] - group_ids = [sanitize_uuid(gid) for gid in set(group_ids)] # sanitize and avoid duplicates + group_ids = _split_comma_separated_values(groups) + group_ids = [sanitize_uuid(gid) for gid in group_ids] params = RequestParameters(user=current_user) uuid = study_service.create_study(name_sanitized, version, group_ids, params) diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 3eff5a22a0..6d0e0e4685 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -1,8 +1,9 @@ import io +import operator +import re import shutil import typing as t import zipfile -from operator import itemgetter from pathlib import Path import pytest @@ -14,6 +15,18 @@ from tests.integration.assets import ASSETS_DIR from tests.integration.utils import wait_task_completion +# URL used to create or list studies +STUDIES_URL = "/v1/studies" + +# Status codes for study creation requests +CREATE_STATUS_CODES = {200, 201} + +# Status code for study listing requests +LIST_STATUS_CODE = 200 + +# Status code for study listing with invalid parameters +INVALID_PARAMS_STATUS_CODE = 422 + class TestStudiesListing: """ @@ -22,6 +35,7 @@ class TestStudiesListing: - GET /v1/studies """ + # noinspection PyUnusedLocal @pytest.fixture(name="to_be_deleted_study_path", autouse=True) def studies_in_ext_fixture(self, tmp_path: Path, app: FastAPI) -> Path: # create a non managed raw study version 840 @@ -59,13 +73,17 @@ def test_study_listing( client: TestClient, admin_access_token: str, to_be_deleted_study_path: Path, - ): + ) -> None: """ This test verifies that database is correctly initialized and then runs the filtering tests with different parameters """ - # database update to include non managed studies + # ========================== + # 1. Database initialization + # ========================== + + # database update to include non managed studies using the watcher res = client.post( "/v1/watcher/_scan", headers={"Authorization": f"Bearer {admin_access_token}"}, @@ -76,15 +94,9 @@ def test_study_listing( task = wait_task_completion(client, admin_access_token, task_id) assert task.status == TaskStatus.COMPLETED, task - accept_status_codes = {200, 201} - studies_url = "/v1/studies" - # retrieve a created non managed + to be deleted studies ids - res = client.get( - studies_url, - headers={"Authorization": f"Bearer {admin_access_token}"}, - ) - assert res.status_code in accept_status_codes, res.json() + res = client.get(STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}) + res.raise_for_status() folder_map = {v["folder"]: k for k, v in res.json().items()} non_managed_840_id = folder_map["ext-840"] non_managed_850_id = folder_map["ext-850"] @@ -111,11 +123,11 @@ def test_study_listing( no_access_code = "NONE" for non_managed_study in non_managed_studies: res = client.put( - f"{studies_url}/{non_managed_study}/public_mode/{no_access_code}", + f"/v1/studies/{non_managed_study}/public_mode/{no_access_code}", headers={"Authorization": f"Bearer {admin_access_token}"}, json={"name": "James Bond", "password": "0007"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() # create a user 'James Bond' with password '007' res = client.post( @@ -123,7 +135,7 @@ def test_study_listing( headers={"Authorization": f"Bearer {admin_access_token}"}, json={"name": "James Bond", "password": "0007"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() james_bond_id = res.json().get("id") # create a user 'John Doe' with password '0011' @@ -132,7 +144,7 @@ def test_study_listing( headers={"Authorization": f"Bearer {admin_access_token}"}, json={"name": "John Doe", "password": "0011"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() john_doe_id = res.json().get("id") # create a group 'Group X' with id 'groupX' @@ -141,7 +153,7 @@ def test_study_listing( headers={"Authorization": f"Bearer {admin_access_token}"}, json={"name": "Group X", "id": "groupX"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() group_x_id = res.json().get("id") assert group_x_id == "groupX" @@ -151,7 +163,7 @@ def test_study_listing( headers={"Authorization": f"Bearer {admin_access_token}"}, json={"name": "Group Y", "id": "groupY"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() group_y_id = res.json().get("id") assert group_y_id == "groupY" @@ -160,7 +172,7 @@ def test_study_listing( "/v1/login", json={"username": "James Bond", "password": "0007"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() assert res.json().get("user") == james_bond_id james_bond_access_token = res.json().get("access_token") @@ -169,7 +181,7 @@ def test_study_listing( "/v1/login", json={"username": "John Doe", "password": "0011"}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() assert res.json().get("user") == john_doe_id john_doe_access_token = res.json().get("access_token") @@ -179,154 +191,154 @@ def test_study_listing( headers={"Authorization": f"Bearer {james_bond_access_token}"}, json={"name": "James Bond", "roles": []}, ) - assert res.status_code in accept_status_codes, res.json() + res.raise_for_status() james_bond_bot_token = res.json() # create a raw study version 840 res = client.post( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "raw-840", "version": "840"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() raw_840_id = res.json() # create a raw study version 850 res = client.post( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "raw-850", "version": "850"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() raw_850_id = res.json() # create a raw study version 860 res = client.post( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "raw-860", "version": "860"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() raw_860_id = res.json() # create a variant study version 840 res = client.post( - f"{studies_url}/{raw_840_id}/variants", + f"{STUDIES_URL}/{raw_840_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "variant-840", "version": "840"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() variant_840_id = res.json() # create a variant study version 850 res = client.post( - f"{studies_url}/{raw_850_id}/variants", + f"{STUDIES_URL}/{raw_850_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "variant-850", "version": "850"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() variant_850_id = res.json() # create a variant study version 860 res = client.post( - f"{studies_url}/{raw_860_id}/variants", + f"{STUDIES_URL}/{raw_860_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "variant-860", "version": "860"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() variant_860_id = res.json() # create a raw study version 840 to be archived res = client.post( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "archived-raw-840", "version": "840"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() archived_raw_840_id = res.json() # create a raw study version 850 to be archived res = client.post( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "archived-raw-850", "version": "850"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() archived_raw_850_id = res.json() # create a variant study version 840 res = client.post( - f"{studies_url}/{archived_raw_840_id}/variants", + f"{STUDIES_URL}/{archived_raw_840_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "archived-variant-840", "version": "840"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() archived_variant_840_id = res.json() # create a variant study version 850 to be archived res = client.post( - f"{studies_url}/{archived_raw_850_id}/variants", + f"{STUDIES_URL}/{archived_raw_850_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "archived-variant-850", "version": "850"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() archived_variant_850_id = res.json() - # create a raw study to be transfered in folder1 + # create a raw study to be transferred in folder1 zip_path = ASSETS_DIR / "STA-mini.zip" res = client.post( - f"{studies_url}/_import", + f"{STUDIES_URL}/_import", headers={"Authorization": f"Bearer {admin_access_token}"}, files={"study": io.BytesIO(zip_path.read_bytes())}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() folder1_study_id = res.json() res = client.put( - f"{studies_url}/{folder1_study_id}/move", + f"{STUDIES_URL}/{folder1_study_id}/move", headers={"Authorization": f"Bearer {admin_access_token}"}, params={"folder_dest": "folder1"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code in CREATE_STATUS_CODES, res.json() # give permission to James Bond for some select studies - james_bond_studies: set = {raw_840_id, variant_850_id, non_managed_860_id} + james_bond_studies = {raw_840_id, variant_850_id, non_managed_860_id} for james_bond_study in james_bond_studies: res = client.put( - f"{studies_url}/{james_bond_study}/owner/{james_bond_id}", + f"{STUDIES_URL}/{james_bond_study}/owner/{james_bond_id}", headers={"Authorization": f"Bearer {admin_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code == 200, res.json() # associate select studies to each group: groupX, groupY - group_x_studies: set = {variant_850_id, raw_860_id} - group_y_studies: set = {raw_850_id, raw_860_id} + group_x_studies = {variant_850_id, raw_860_id} + group_y_studies = {raw_850_id, raw_860_id} for group_x_study in group_x_studies: res = client.put( - f"{studies_url}/{group_x_study}/groups/{group_x_id}", + f"{STUDIES_URL}/{group_x_study}/groups/{group_x_id}", headers={"Authorization": f"Bearer {admin_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code == 200, res.json() for group_y_study in group_y_studies: res = client.put( - f"{studies_url}/{group_y_study}/groups/{group_y_id}", + f"{STUDIES_URL}/{group_y_study}/groups/{group_y_id}", headers={"Authorization": f"Bearer {admin_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code == 200, res.json() # archive studies archive_studies = {archived_raw_840_id, archived_raw_850_id} for archive_study in archive_studies: res = client.put( - f"{studies_url}/{archive_study}/archive", + f"{STUDIES_URL}/{archive_study}/archive", headers={"Authorization": f"Bearer {admin_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code == 200, res.json() archiving_study_task_id = res.json() task = wait_task_completion(client, admin_access_token, archiving_study_task_id) assert task.status == TaskStatus.COMPLETED, task # the testing studies set - all_studies: set = { + all_studies = { raw_840_id, raw_850_id, raw_860_id, @@ -344,33 +356,35 @@ def test_study_listing( to_be_deleted_id, } - pm = itemgetter("public_mode") + pm = operator.itemgetter("public_mode") # tests (1) for user permission filtering # test 1.a for a user with no access permission res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {john_doe_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map: t.Dict[str, t.Dict[str, t.Any]] = res.json() assert not all_studies.intersection(study_map) assert all(map(lambda x: pm(x) in [PublicMode.READ, PublicMode.FULL], study_map.values())) + # test 1.b for an admin user res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(study_map) + # test 1.c for a user with access to select studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {james_bond_access_token}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not james_bond_studies.difference(study_map) assert all( map( @@ -379,14 +393,16 @@ def test_study_listing( ) ) # #TODO you need to update the permission for James Bond bot + # test 1.d for a user bot with access to select studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {james_bond_bot_token}"}, ) - assert res.status_code in accept_status_codes, res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + # #TODO add the correct test assertions - # study_map: t.Dict[str, t.Any] = res.json() + # ] = res.json() # assert not set(james_bond_studies).difference(study_map) # assert all( # map( @@ -397,62 +413,62 @@ def test_study_listing( # tests (2) for studies names filtering # test 2.a with matching studies - res = client.get(studies_url, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "840"}) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + res = client.get(STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "840"}) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert all(map(lambda x: "840" in x.get("name"), study_map.values())) and len(study_map) >= 5 # test 2.b with no matching studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"name": "NON-SENSE-746846351469798465"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not study_map # tests (3) managed studies vs non managed # test 3.a managed managed_studies = all_studies.difference(non_managed_studies) res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"managed": True}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not managed_studies.difference(study_map) assert not all_studies.difference(managed_studies).intersection(study_map) # test 3.b non managed res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"managed": False}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(managed_studies).difference(study_map) assert not managed_studies.intersection(study_map) # tests (4) archived vs non archived # test 4.a archived studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"archived": True}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not archive_studies.difference(study_map) assert not all_studies.difference(archive_studies).intersection(study_map) # test 4.b non archived res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"archived": False}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(archive_studies).difference(study_map) assert not archive_studies.intersection(study_map) @@ -466,100 +482,100 @@ def test_study_listing( } # test 5.a get variant studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"variant": True}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not variant_studies.difference(study_map) assert not all_studies.difference(variant_studies).intersection(study_map) # test 5.b get raw studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"variant": False}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(variant_studies).difference(study_map) assert not variant_studies.intersection(study_map) # tests (6) for version filtering - studies_version_850: set = { + studies_version_850 = { raw_850_id, non_managed_850_id, variant_850_id, archived_raw_850_id, archived_variant_850_id, } - studies_version_860: set = { + studies_version_860 = { raw_860_id, non_managed_860_id, variant_860_id, } # test 6.a filter for one version: 860 res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"versions": "860"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(studies_version_860).intersection(study_map) assert not studies_version_860.difference(study_map) # test 8.b filter for two versions: 850, 860 res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"versions": "850,860"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(studies_version_850.union(studies_version_860)).intersection(study_map) assert not studies_version_850.union(studies_version_860).difference(study_map) # tests (7) for users filtering # test 7.a to get studies for one user: James Bond res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"users": f"{james_bond_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(james_bond_studies).intersection(study_map) assert not james_bond_studies.difference(study_map) # test 7.b to get studies for two users res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"users": f"{james_bond_id},{john_doe_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(james_bond_studies).intersection(study_map) assert not james_bond_studies.difference(study_map) # tests (8) for groups filtering # test 8.a filter for one group: groupX res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"groups": f"{group_x_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(group_x_studies).intersection(study_map) assert not group_x_studies.difference(study_map) # test 8.b filter for two groups: groupX, groupY res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"groups": f"{group_x_id},{group_y_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(group_x_studies.union(group_y_studies)).intersection(study_map) assert not group_x_studies.union(group_y_studies).difference(study_map) @@ -571,43 +587,43 @@ def test_study_listing( # tests (10) for studies uuids sequence filtering # test 10.a filter for one uuid res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, - params={"studiesIds": f"{raw_840_id}"}, + params={"studyIds": f"{raw_840_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert {raw_840_id} == set(study_map) # test 10.b filter for two uuids res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, - params={"studiesIds": f"{raw_840_id},{raw_860_id}"}, + params={"studyIds": f"{raw_840_id},{raw_860_id}"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert {raw_840_id, raw_860_id} == set(study_map) # tests (11) studies filtering regarding existence on disk existing_studies = all_studies.difference({to_be_deleted_id}) # test 11.a filter existing studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"exists": True}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not existing_studies.difference(study_map) assert not all_studies.difference(existing_studies).intersection(study_map) # test 11.b filter non-existing studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"exists": False}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not all_studies.difference(existing_studies).difference(study_map) assert not existing_studies.intersection(study_map) @@ -615,23 +631,134 @@ def test_study_listing( ext_workspace_studies = non_managed_studies # test 12.a filter `ext` workspace studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"workspace": "ext"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not ext_workspace_studies.difference(study_map) assert not all_studies.difference(ext_workspace_studies).intersection(study_map) # tests (13) studies filtering with folder # test 13.a filter `folder1` studies res = client.get( - studies_url, + STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, params={"folder": "folder1"}, ) - assert res.status_code in accept_status_codes, res.json() - study_map: t.Dict[str, t.Any] = res.json() + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() assert not {folder1_study_id}.difference(study_map) assert not all_studies.difference({folder1_study_id}).intersection(study_map) + + # test sort by name ASC + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"sortBy": "+name"}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + values = list(study_map.values()) + assert values == sorted(values, key=lambda x: x["name"].upper()) + + # test sort by name DESC + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"sortBy": "-name"}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + values = list(study_map.values()) + assert values == sorted(values, key=lambda x: x["name"].upper(), reverse=True) + + # test sort by date ASC + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"sortBy": "+date"}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + values = list(study_map.values()) + assert values == sorted(values, key=lambda x: x["created"]) + + # test sort by date DESC + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"sortBy": "-date"}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + values = list(study_map.values()) + assert values == sorted(values, key=lambda x: x["created"], reverse=True) + + def test_get_studies__invalid_parameters( + self, + client: TestClient, + user_access_token: str, + ) -> None: + headers = {"Authorization": f"Bearer {user_access_token}"} + + # Invalid `sortBy` parameter + res = client.get(STUDIES_URL, headers=headers, params={"sortBy": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"not a valid enumeration member", description), f"{description=}" + + # Invalid `pageNb` parameter (negative integer) + res = client.get(STUDIES_URL, headers=headers, params={"pageNb": -1}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"greater than or equal to 0", description), f"{description=}" + + # Invalid `pageNb` parameter (not an integer) + res = client.get(STUDIES_URL, headers=headers, params={"pageNb": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"not a valid integer", description), f"{description=}" + + # Invalid `pageSize` parameter (negative integer) + res = client.get(STUDIES_URL, headers=headers, params={"pageSize": -1}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"greater than or equal to 0", description), f"{description=}" + + # Invalid `pageSize` parameter (not an integer) + res = client.get(STUDIES_URL, headers=headers, params={"pageSize": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"not a valid integer", description), f"{description=}" + + # Invalid `managed` parameter (not a boolean) + res = client.get(STUDIES_URL, headers=headers, params={"managed": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"could not be parsed to a boolean", description), f"{description=}" + + # Invalid `archived` parameter (not a boolean) + res = client.get(STUDIES_URL, headers=headers, params={"archived": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"could not be parsed to a boolean", description), f"{description=}" + + # Invalid `variant` parameter (not a boolean) + res = client.get(STUDIES_URL, headers=headers, params={"variant": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"could not be parsed to a boolean", description), f"{description=}" + + # Invalid `users` parameter (not a list of integers) + res = client.get(STUDIES_URL, headers=headers, params={"users": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"must be a list of integers", description), f"{description=}" + + # Invalid `exists` parameter (not a boolean) + res = client.get(STUDIES_URL, headers=headers, params={"exists": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"could not be parsed to a boolean", description), f"{description=}" diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index abe422c479..77e4f1554c 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -1,5 +1,5 @@ +import datetime import typing as t -from datetime import datetime from unittest.mock import Mock import pytest @@ -7,14 +7,14 @@ from antarest.core.interfaces.cache import ICache from antarest.login.model import Group, User -from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study +from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy from antarest.study.repository import StudyFilter, StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @pytest.mark.parametrize( - "managed, studies_ids, exists, expected_ids", + "managed, study_ids, exists, expected_ids", [ (None, [], False, {"5", "6"}), (None, [], True, {"1", "2", "3", "4", "7", "8"}), @@ -41,10 +41,10 @@ def test_repository_get_all__general_case( db_session: Session, managed: t.Union[bool, None], - studies_ids: t.Union[t.List[str], None], + study_ids: t.List[str], exists: t.Union[bool, None], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: test_workspace = "test-repository" icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -53,8 +53,8 @@ def test_repository_get_all__general_case( study_2 = VariantStudy(id=2) study_3 = VariantStudy(id=3) study_4 = VariantStudy(id=4) - study_5 = RawStudy(id=5, missing=datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) - study_6 = RawStudy(id=6, missing=datetime.now(), workspace=test_workspace) + study_5 = RawStudy(id=5, missing=datetime.datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(id=6, missing=datetime.datetime.now(), workspace=test_workspace) study_7 = RawStudy(id=7, missing=None, workspace=test_workspace) study_8 = RawStudy(id=8, missing=None, workspace=DEFAULT_WORKSPACE_NAME) @@ -66,21 +66,19 @@ def test_repository_get_all__general_case( # 2- accessing studies attributes does require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter(managed=managed, studies_ids=studies_ids, exists=exists) - ) + all_studies = repository.get_all(study_filter=StudyFilter(managed=managed, study_ids=study_ids, exists=exists)) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids def test_repository_get_all__incompatible_case( db_session: Session, -): +) -> None: test_workspace = "workspace1" icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -89,8 +87,8 @@ def test_repository_get_all__incompatible_case( study_2 = VariantStudy(id=2) study_3 = VariantStudy(id=3) study_4 = VariantStudy(id=4) - study_5 = RawStudy(id=5, missing=datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) - study_6 = RawStudy(id=6, missing=datetime.now(), workspace=test_workspace) + study_5 = RawStudy(id=5, missing=datetime.datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(id=6, missing=datetime.datetime.now(), workspace=test_workspace) study_7 = RawStudy(id=7, missing=None, workspace=test_workspace) study_8 = RawStudy(id=8, missing=None, workspace=DEFAULT_WORKSPACE_NAME) @@ -105,7 +103,7 @@ def test_repository_get_all__incompatible_case( _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) - assert set([s.id for s in all_studies]) == set() + assert not {s.id for s in all_studies} # case 2 study_filter = StudyFilter(workspace=test_workspace, variant=True) @@ -115,7 +113,7 @@ def test_repository_get_all__incompatible_case( _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) - assert set([s.id for s in all_studies]) == set() + assert not {s.id for s in all_studies} # case 3 study_filter = StudyFilter(exists=False, variant=True) @@ -125,7 +123,7 @@ def test_repository_get_all__incompatible_case( _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) - assert set([s.id for s in all_studies]) == set() + assert not {s.id for s in all_studies} @pytest.mark.parametrize( @@ -145,8 +143,8 @@ def test_repository_get_all__incompatible_case( def test_repository_get_all__study_name_filter( db_session: Session, name: str, - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -174,7 +172,7 @@ def test_repository_get_all__study_name_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -188,8 +186,8 @@ def test_repository_get_all__study_name_filter( def test_repository_get_all__managed_study_filter( db_session: Session, managed: t.Optional[bool], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: test_workspace = "test-workspace" icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -218,7 +216,7 @@ def test_repository_get_all__managed_study_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -232,8 +230,8 @@ def test_repository_get_all__managed_study_filter( def test_repository_get_all__archived_study_filter( db_session: Session, archived: t.Optional[bool], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -257,7 +255,7 @@ def test_repository_get_all__archived_study_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -271,8 +269,8 @@ def test_repository_get_all__archived_study_filter( def test_repository_get_all__variant_study_filter( db_session: Session, variant: t.Optional[bool], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -296,7 +294,7 @@ def test_repository_get_all__variant_study_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -312,8 +310,8 @@ def test_repository_get_all__variant_study_filter( def test_repository_get_all__study_version_filter( db_session: Session, versions: t.List[str], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -337,7 +335,7 @@ def test_repository_get_all__study_version_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -353,8 +351,8 @@ def test_repository_get_all__study_version_filter( def test_repository_get_all__study_users_filter( db_session: Session, users: t.List["int"], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -384,7 +382,7 @@ def test_repository_get_all__study_users_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -400,8 +398,8 @@ def test_repository_get_all__study_users_filter( def test_repository_get_all__study_groups_filter( db_session: Session, groups: t.List[str], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -431,11 +429,11 @@ def test_repository_get_all__study_groups_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( - "studies_ids, expected_ids", + "study_ids, expected_ids", [ ([], {"1", "2", "3", "4"}), (["1", "2", "3", "4"], {"1", "2", "3", "4"}), @@ -445,11 +443,11 @@ def test_repository_get_all__study_groups_filter( (["3000"], set()), ], ) -def test_repository_get_all__studies_ids_filter( +def test_repository_get_all__study_ids_filter( db_session: Session, - studies_ids: t.List[str], - expected_ids: set, -): + study_ids: t.List[str], + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -466,14 +464,14 @@ def test_repository_get_all__studies_ids_filter( # 2- accessing studies attributes does require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(studies_ids=studies_ids)) + all_studies = repository.get_all(study_filter=StudyFilter(study_ids=study_ids)) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -487,14 +485,14 @@ def test_repository_get_all__studies_ids_filter( def test_repository_get_all__study_existence_filter( db_session: Session, exists: t.Optional[bool], - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) study_1 = VariantStudy(id=1) study_2 = VariantStudy(id=2) - study_3 = RawStudy(id=3, missing=datetime.now()) + study_3 = RawStudy(id=3, missing=datetime.datetime.now()) study_4 = RawStudy(id=4) db_session.add_all([study_1, study_2, study_3, study_4]) @@ -512,7 +510,7 @@ def test_repository_get_all__study_existence_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -527,8 +525,8 @@ def test_repository_get_all__study_existence_filter( def test_repository_get_all__study_workspace_filter( db_session: Session, workspace: str, - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -552,7 +550,7 @@ def test_repository_get_all__study_workspace_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids @pytest.mark.parametrize( @@ -569,8 +567,8 @@ def test_repository_get_all__study_workspace_filter( def test_repository_get_all__study_folder_filter( db_session: Session, folder: str, - expected_ids: set, -): + expected_ids: t.Set[str], +) -> None: icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) @@ -594,4 +592,4 @@ def test_repository_get_all__study_folder_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: - assert set([s.id for s in all_studies]) == expected_ids + assert {s.id for s in all_studies} == expected_ids From 73db3707725d4cfac4ce4195fa40f340080deab4 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 08:06:20 +0100 Subject: [PATCH 07/13] style(study-search): correct typo in comment --- tests/integration/studies_blueprint/test_get_studies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 6d0e0e4685..6026ecaa40 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -94,7 +94,7 @@ def test_study_listing( task = wait_task_completion(client, admin_access_token, task_id) assert task.status == TaskStatus.COMPLETED, task - # retrieve a created non managed + to be deleted studies ids + # retrieve a created non managed + to be deleted study IDs res = client.get(STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}) res.raise_for_status() folder_map = {v["folder"]: k for k, v in res.json().items()} From b3c3b17bc146991fe94883d5fc49a11e469eb135 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 08:26:57 +0100 Subject: [PATCH 08/13] feat(study-search): turn the `sortBy` parameter into an optional parameter It is advisable to use an optional Query parameter for enumerated types, like booleans. --- antarest/study/repository.py | 7 +++---- antarest/study/service.py | 9 ++++----- antarest/study/web/studies_blueprint.py | 9 +++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 47a33e5d7f..00d65a79b9 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -50,7 +50,7 @@ class StudyFilter(BaseModel, frozen=True, extra="forbid"): users: users to filter by groups: groups to filter by tags: tags to filter by - study_ids: studies ids to filter by + study_ids: study IDs to filter by exists: if raw study missing workspace: optional workspace of the study folder: optional folder prefix of the study @@ -73,7 +73,6 @@ class StudyFilter(BaseModel, frozen=True, extra="forbid"): class StudySortBy(str, enum.Enum): """How to sort the results of studies query results""" - NO_SORT = "" NAME_ASC = "+name" NAME_DESC = "-name" DATE_ASC = "+date" @@ -182,7 +181,7 @@ def get_additional_data(self, study_id: str) -> t.Optional[StudyAdditionalData]: def get_all( self, study_filter: StudyFilter = StudyFilter(), - sort_by: StudySortBy = StudySortBy.NO_SORT, + sort_by: t.Optional[StudySortBy] = None, pagination: StudyPagination = StudyPagination(), ) -> t.List[Study]: """ @@ -243,7 +242,7 @@ def get_all( if study_filter.versions: q = q.filter(entity.version.in_(study_filter.versions)) - if sort_by != StudySortBy.NO_SORT: + if sort_by: if sort_by == StudySortBy.DATE_DESC: q = q.order_by(entity.created_at.desc()) elif sort_by == StudySortBy.DATE_ASC: diff --git a/antarest/study/service.py b/antarest/study/service.py index 94ea7d4bba..0247e01a1e 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -4,11 +4,10 @@ import json import logging import os -import typing as t +import time from datetime import datetime, timedelta from http import HTTPStatus from pathlib import Path, PurePosixPath -from time import time from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast from uuid import uuid4 @@ -110,7 +109,7 @@ should_study_be_denormalized, upgrade_study, ) -from antarest.study.storage.utils import assert_permission, get_start_date, is_managed, remove_from_cache, study_matcher +from antarest.study.storage.utils import assert_permission, get_start_date, is_managed, remove_from_cache from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_comments import UpdateComments @@ -441,7 +440,7 @@ def get_studies_information( self, params: RequestParameters, study_filter: StudyFilter, - sort_by: StudySortBy = StudySortBy.NO_SORT, + sort_by: Optional[StudySortBy] = None, pagination: StudyPagination = StudyPagination(), ) -> Dict[str, StudyMetadataDTO]: """ @@ -1474,7 +1473,7 @@ def _edit_study_using_command( # noinspection SpellCheckingInspection url = "study/antares/lastsave" last_save_node = file_study.tree.get_node(url.split("/")) - cmd = self._create_edit_study_command(tree_node=last_save_node, url=url, data=int(time())) + cmd = self._create_edit_study_command(tree_node=last_save_node, url=url, data=int(time.time())) cmd.apply(file_study) self.storage_service.variant_study_service.invalidate_cache(study) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 8056d23e2e..244b8425d0 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -88,9 +88,10 @@ def get_studies( exists: Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), workspace: str = Query("", description="Filter studies based on their workspace."), folder: str = Query("", description="Filter studies based on their folder."), - sort_by: StudySortBy = Query( - StudySortBy.NO_SORT, - description="Sort studies based on their name (case-insensitive) or date.", + # It is advisable to use an optional Query parameter for enumerated types, like booleans. + sort_by: Optional[StudySortBy] = Query( + None, + description="Sort studies based on their name (case-insensitive) or creation date.", alias="sortBy", ), page_nb: NonNegativeInt = Query( @@ -158,7 +159,7 @@ def get_studies( matching_studies = study_service.get_studies_information( params=params, study_filter=study_filter, - sort_by=StudySortBy(sort_by), + sort_by=sort_by, pagination=StudyPagination(page_nb=page_nb, page_size=page_size), ) From b9331b92a8ccc5ac3bc863971a6bf624304c23b3 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 10:02:31 +0100 Subject: [PATCH 09/13] style(study-search): use `t` alias to import `typing` --- antarest/study/service.py | 110 ++++++++++++------------ antarest/study/web/studies_blueprint.py | 74 ++++++++-------- 2 files changed, 94 insertions(+), 90 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 0247e01a1e..98d36dee9b 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -5,10 +5,10 @@ import logging import os import time +import typing as t from datetime import datetime, timedelta from http import HTTPStatus from pathlib import Path, PurePosixPath -from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast from uuid import uuid4 import numpy as np @@ -126,7 +126,7 @@ MAX_MISSING_STUDY_TIMEOUT = 2 # days -def get_disk_usage(path: Union[str, Path]) -> int: +def get_disk_usage(path: t.Union[str, Path]) -> int: path = Path(path) if path.suffix.lower() in {".zip", "7z"}: return os.path.getsize(path) @@ -269,9 +269,9 @@ def __init__( self.binding_constraint_manager = BindingConstraintManager(self.storage_service) self.cache_service = cache_service self.config = config - self.on_deletion_callbacks: List[Callable[[str], None]] = [] + self.on_deletion_callbacks: t.List[t.Callable[[str], None]] = [] - def add_on_deletion_callback(self, callback: Callable[[str], None]) -> None: + def add_on_deletion_callback(self, callback: t.Callable[[str], None]) -> None: self.on_deletion_callbacks.append(callback) def _on_study_delete(self, uuid: str) -> None: @@ -311,7 +311,7 @@ def get_logs( job_id: str, err_log: bool, params: RequestParameters, - ) -> Optional[str]: + ) -> t.Optional[str]: study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.READ) file_study = self.storage_service.get_storage(study).get_raw(study) @@ -331,7 +331,7 @@ def get_logs( empty_log = False for log_location in log_locations[err_log]: try: - log = cast( + log = t.cast( bytes, file_study.tree.get(log_location, depth=1, formatted=True), ).decode(encoding="utf-8") @@ -367,9 +367,9 @@ def save_logs( f"{job_id}-{log_suffix}", ], ) - stopwatch.log_elapsed(lambda t: logger.info(f"Saved logs for job {job_id} in {t}s")) + stopwatch.log_elapsed(lambda d: logger.info(f"Saved logs for job {job_id} in {d}s")) - def get_comments(self, study_id: str, params: RequestParameters) -> Union[str, JSON]: + def get_comments(self, study_id: str, params: RequestParameters) -> t.Union[str, JSON]: """ Get the comments of a study. @@ -382,7 +382,7 @@ def get_comments(self, study_id: str, params: RequestParameters) -> Union[str, J study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.READ) - output: Union[str, JSON] + output: t.Union[str, JSON] raw_study_service = self.storage_service.raw_study_service variant_study_service = self.storage_service.variant_study_service if isinstance(study, RawStudy): @@ -440,9 +440,9 @@ def get_studies_information( self, params: RequestParameters, study_filter: StudyFilter, - sort_by: Optional[StudySortBy] = None, + sort_by: t.Optional[StudySortBy] = None, pagination: StudyPagination = StudyPagination(), - ) -> Dict[str, StudyMetadataDTO]: + ) -> t.Dict[str, StudyMetadataDTO]: """ Get information for matching studies of a search query. Args: @@ -454,7 +454,7 @@ def get_studies_information( Returns: List of study information """ logger.info("Retrieving matching studies") - studies: Dict[str, StudyMetadataDTO] = {} + studies: t.Dict[str, StudyMetadataDTO] = {} matching_studies = self.repository.get_all( study_filter=study_filter, sort_by=sort_by, @@ -478,7 +478,7 @@ def get_studies_information( ) } - def _try_get_studies_information(self, study: Study) -> Optional[StudyMetadataDTO]: + def _try_get_studies_information(self, study: Study) -> t.Optional[StudyMetadataDTO]: try: return self.storage_service.get_storage(study).get_study_information(study) except Exception as e: @@ -599,8 +599,8 @@ def get_study_path(self, uuid: str, params: RequestParameters) -> Path: def create_study( self, study_name: str, - version: Optional[str], - group_ids: List[str], + version: t.Optional[str], + group_ids: t.List[str], params: RequestParameters, ) -> str: """ @@ -680,7 +680,9 @@ def get_study_synthesis(self, study_id: str, params: RequestParameters) -> FileS study_storage_service = self.storage_service.get_storage(study) return study_storage_service.get_synthesis(study, params) - def get_input_matrix_startdate(self, study_id: str, path: Optional[str], params: RequestParameters) -> MatrixIndex: + def get_input_matrix_startdate( + self, study_id: str, path: t.Optional[str], params: RequestParameters + ) -> MatrixIndex: study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.READ) file_study = self.storage_service.get_storage(study).get_raw(study) @@ -696,7 +698,7 @@ def get_input_matrix_startdate(self, study_id: str, path: Optional[str], params: return get_start_date(file_study, output_id, level) def remove_duplicates(self) -> None: - study_paths: Dict[str, List[str]] = {} + study_paths: t.Dict[str, t.List[str]] = {} for study in self.repository.get_all(): if isinstance(study, RawStudy) and not study.archived: path = str(study.path) @@ -711,7 +713,7 @@ def remove_duplicates(self) -> None: logger.info(f"Removing study {study_name}") self.repository.delete(study_name) - def sync_studies_on_disk(self, folders: List[StudyFolder], directory: Optional[Path] = None) -> None: + def sync_studies_on_disk(self, folders: t.List[StudyFolder], directory: t.Optional[Path] = None) -> None: """ Used by watcher to send list of studies present on filesystem. @@ -820,7 +822,7 @@ def copy_study( self, src_uuid: str, dest_study_name: str, - group_ids: List[str], + group_ids: t.List[str], use_task: bool, params: RequestParameters, with_outputs: bool = False, @@ -954,7 +956,7 @@ def output_variables_information( study_uuid: str, output_uuid: str, params: RequestParameters, - ) -> Dict[str, List[str]]: + ) -> t.Dict[str, t.List[str]]: """ Returns information about output variables using thematic and geographic trimming information Args: @@ -1027,7 +1029,7 @@ def export_study_flat( uuid: str, params: RequestParameters, dest: Path, - output_list: Optional[List[str]] = None, + output_list: t.Optional[t.List[str]] = None, ) -> None: logger.info(f"Flat exporting study {uuid}") study = self.get_study(uuid) @@ -1040,20 +1042,20 @@ def export_study_flat( def delete_study(self, uuid: str, children: bool, params: RequestParameters) -> None: """ - Delete study + Delete study and all its children + Args: uuid: study uuid + children: delete children or not params: request parameters - - Returns: - """ study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.WRITE) study_info = study.to_json_summary() - # this prefetch the workspace because it is lazy loaded and the object is deleted before using workspace attribute in raw study deletion + # this prefetch the workspace because it is lazy loaded and the object is deleted + # before using workspace attribute in raw study deletion # see https://github.com/AntaresSimulatorTeam/AntaREST/issues/606 if isinstance(study, RawStudy): _ = study.workspace @@ -1124,8 +1126,8 @@ def download_outputs( use_task: bool, filetype: ExportFormat, params: RequestParameters, - tmp_export_file: Optional[Path] = None, - ) -> Union[Response, FileDownloadTaskDTO, FileResponse]: + tmp_export_file: t.Optional[Path] = None, + ) -> t.Union[Response, FileDownloadTaskDTO, FileResponse]: """ Download outputs Args: @@ -1226,7 +1228,7 @@ def export_task(notifier: TaskUpdateNotifier) -> TaskResult: ).encode("utf-8") return Response(content=json_response, media_type="application/json") - def get_study_sim_result(self, study_id: str, params: RequestParameters) -> List[StudySimResultDTO]: + def get_study_sim_result(self, study_id: str, params: RequestParameters) -> t.List[StudySimResultDTO]: """ Get global result information Args: @@ -1280,8 +1282,8 @@ def set_sim_reference( def import_study( self, - stream: BinaryIO, - group_ids: List[str], + stream: t.BinaryIO, + group_ids: t.List[str], params: RequestParameters, ) -> str: """ @@ -1332,11 +1334,11 @@ def import_study( def import_output( self, uuid: str, - output: Union[BinaryIO, Path], + output: t.Union[t.BinaryIO, Path], params: RequestParameters, - output_name_suffix: Optional[str] = None, + output_name_suffix: t.Optional[str] = None, auto_unzip: bool = True, - ) -> Optional[str]: + ) -> t.Optional[str]: """ Import specific output simulation inside study Args: @@ -1490,7 +1492,9 @@ def _edit_study_using_command( return command # for testing purpose - def apply_commands(self, uuid: str, commands: List[CommandDTO], params: RequestParameters) -> Optional[List[str]]: + def apply_commands( + self, uuid: str, commands: t.List[CommandDTO], params: RequestParameters + ) -> t.Optional[t.List[str]]: study = self.get_study(uuid) if isinstance(study, VariantStudy): return self.storage_service.variant_study_service.append_commands(uuid, commands, params) @@ -1498,7 +1502,7 @@ def apply_commands(self, uuid: str, commands: List[CommandDTO], params: RequestP file_study = self.storage_service.raw_study_service.get_raw(study) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) - parsed_commands: List[ICommand] = [] + parsed_commands: t.List[ICommand] = [] for command in commands: parsed_commands.extend(self.storage_service.variant_study_service.command_factory.to_command(command)) execute_or_add_commands( @@ -1561,7 +1565,7 @@ def edit_study( uuid, params.get_user_id(), ) - return cast(JSON, new) + return t.cast(JSON, new) def change_owner(self, study_id: str, owner_id: int, params: RequestParameters) -> None: """ @@ -1689,7 +1693,7 @@ def set_public_mode(self, study_id: str, mode: PublicMode, params: RequestParame params.get_user_id(), ) - def check_errors(self, uuid: str) -> List[str]: + def check_errors(self, uuid: str) -> t.List[str]: study = self.get_study(uuid) self._assert_study_unarchived(study) return self.storage_service.raw_study_service.check_errors(study) @@ -1697,10 +1701,10 @@ def check_errors(self, uuid: str) -> List[str]: def get_all_areas( self, uuid: str, - area_type: Optional[AreaType], + area_type: t.Optional[AreaType], ui: bool, params: RequestParameters, - ) -> Union[List[AreaInfoDTO], Dict[str, Any]]: + ) -> t.Union[t.List[AreaInfoDTO], t.Dict[str, t.Any]]: study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.READ) return self.areas.get_all_areas_ui_info(study) if ui else self.areas.get_all_areas(study, area_type) @@ -1710,7 +1714,7 @@ def get_all_links( uuid: str, with_ui: bool, params: RequestParameters, - ) -> List[LinkInfoDTO]: + ) -> t.List[LinkInfoDTO]: study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.READ) return self.links.get_all_links(study, with_ui) @@ -1790,7 +1794,7 @@ def update_thermal_cluster_metadata( self, uuid: str, area_id: str, - clusters_metadata: Dict[str, PatchCluster], + clusters_metadata: t.Dict[str, PatchCluster], params: RequestParameters, ) -> AreaInfoDTO: study = self.get_study(uuid) @@ -1925,8 +1929,8 @@ def unarchive_task(notifier: TaskUpdateNotifier) -> TaskResult: def _save_study( self, study: Study, - owner: Optional[JWTUser] = None, - group_ids: Sequence[str] = (), + owner: t.Optional[JWTUser] = None, + group_ids: t.Sequence[str] = (), content_status: StudyContentStatus = StudyContentStatus.VALID, ) -> None: """ @@ -1956,7 +1960,7 @@ def _save_study( study.groups.clear() for gid in group_ids: - jwt_group: Optional[JWTGroup] = next(filter(lambda g: g.id == gid, owner.groups), None) # type: ignore + jwt_group: t.Optional[JWTGroup] = next(filter(lambda g: g.id == gid, owner.groups), None) # type: ignore if ( jwt_group is None or jwt_group.role is None @@ -2017,13 +2021,13 @@ def _analyse_study(self, metadata: Study) -> StudyContentStatus: # noinspection PyUnusedLocal @staticmethod - def get_studies_versions(params: RequestParameters) -> List[str]: + def get_studies_versions(params: RequestParameters) -> t.List[str]: return list(STUDY_REFERENCE_TEMPLATES) def create_xpansion_configuration( self, uuid: str, - zipped_config: Optional[UploadFile], + zipped_config: t.Optional[UploadFile], params: RequestParameters, ) -> None: study = self.get_study(uuid) @@ -2069,7 +2073,7 @@ def get_candidate(self, uuid: str, candidate_name: str, params: RequestParameter assert_permission(params.user, study, StudyPermissionType.READ) return self.xpansion_manager.get_candidate(study, candidate_name) - def get_candidates(self, uuid: str, params: RequestParameters) -> List[XpansionCandidateDTO]: + def get_candidates(self, uuid: str, params: RequestParameters) -> t.List[XpansionCandidateDTO]: study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.READ) return self.xpansion_manager.get_candidates(study) @@ -2107,7 +2111,7 @@ def update_matrix( self, uuid: str, path: str, - matrix_edit_instruction: List[MatrixEditInstruction], + matrix_edit_instruction: t.List[MatrixEditInstruction], params: RequestParameters, ) -> None: """ @@ -2155,7 +2159,7 @@ def archive_outputs(self, study_id: str, params: RequestParameters) -> None: self.archive_output(study_id, output, params) @staticmethod - def _get_output_archive_task_names(study: Study, output_id: str) -> Tuple[str, str]: + def _get_output_archive_task_names(study: Study, output_id: str) -> t.Tuple[str, str]: return ( f"Archive output {study.id}/{output_id}", f"Unarchive output {study.name}/{output_id} ({study.id})", @@ -2167,7 +2171,7 @@ def archive_output( output_id: str, params: RequestParameters, force: bool = False, - ) -> Optional[str]: + ) -> t.Optional[str]: study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) @@ -2224,7 +2228,7 @@ def unarchive_output( output_id: str, keep_src_zip: bool, params: RequestParameters, - ) -> Optional[str]: + ) -> t.Optional[str]: study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.READ) self._assert_study_unarchived(study) @@ -2264,7 +2268,7 @@ def unarchive_output_task( ) raise e - task_id: Optional[str] = None + task_id: t.Optional[str] = None workspace = getattr(study, "workspace", DEFAULT_WORKSPACE_NAME) if workspace != DEFAULT_WORKSPACE_NAME: dest = Path(study.path) / "output" / output_id diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 244b8425d0..daec898d67 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -2,9 +2,9 @@ import io import logging import re +import typing as t from http import HTTPStatus from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, File, HTTPException, Query, Request from markupsafe import escape @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) -def _split_comma_separated_values(value: str, *, default: Sequence[str] = ()) -> Sequence[str]: +def _split_comma_separated_values(value: str, *, default: t.Sequence[str] = ()) -> t.Sequence[str]: """Split a comma-separated list of values into an ordered set of strings.""" value = value.strip() value_list = re.split(r"\s*,\s*", value) if value else default @@ -73,9 +73,9 @@ def get_studies( ), alias="name", ), - managed: Optional[bool] = Query(None, description="Filter studies based on their management status."), - archived: Optional[bool] = Query(None, description="Filter studies based on their archive status."), - variant: Optional[bool] = Query(None, description="Filter studies based on their variant status."), + managed: t.Optional[bool] = Query(None, description="Filter studies based on their management status."), + archived: t.Optional[bool] = Query(None, description="Filter studies based on their archive status."), + variant: t.Optional[bool] = Query(None, description="Filter studies based on their variant status."), versions: str = Query("", description="Comma-separated list of versions for filtering."), users: str = Query("", description="Comma-separated list of group IDs for filtering."), groups: str = Query("", description="Comma-separated list of group IDs for filtering."), @@ -85,11 +85,11 @@ def get_studies( description="Comma-separated list of study IDs for filtering.", alias="studyIds", ), - exists: Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), + exists: t.Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), workspace: str = Query("", description="Filter studies based on their workspace."), folder: str = Query("", description="Filter studies based on their folder."), # It is advisable to use an optional Query parameter for enumerated types, like booleans. - sort_by: Optional[StudySortBy] = Query( + sort_by: t.Optional[StudySortBy] = Query( None, description="Sort studies based on their name (case-insensitive) or creation date.", alias="sortBy", @@ -104,7 +104,7 @@ def get_studies( description="Number of studies per page (0 = no limit).", alias="pageSize", ), - ) -> Dict[str, StudyMetadataDTO]: + ) -> t.Dict[str, StudyMetadataDTO]: """ Get the list of studies matching the specified criteria. @@ -173,7 +173,7 @@ def get_studies( def get_comments( uuid: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Get comments of study {uuid}", extra={"user": current_user.id}) params = RequestParameters(user=current_user) study_id = sanitize_uuid(uuid) @@ -190,7 +190,7 @@ def edit_comments( uuid: str, data: CommentsDto, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Editing comments for study {uuid}", extra={"user": current_user.id}, @@ -337,7 +337,7 @@ def move_study( uuid: str, folder_dest: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Moving study {uuid} into folder '{folder_dest}'", extra={"user": current_user.id}, @@ -357,7 +357,7 @@ def create_study( version: str = "", groups: str = "", current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Creating new study '{name}'", extra={"user": current_user.id}) name_sanitized = escape(name) group_ids = _split_comma_separated_values(groups) @@ -377,7 +377,7 @@ def create_study( def get_study_synthesis( uuid: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(uuid) logger.info( f"Return a synthesis for study '{study_id}'", @@ -396,7 +396,7 @@ def get_study_matrix_index( uuid: str, path: str = "", current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(uuid) logger.info( f"Return the start date for input matrix '{study_id}'", @@ -413,9 +413,9 @@ def get_study_matrix_index( ) def export_study( uuid: str, - no_output: Optional[bool] = False, + no_output: t.Optional[bool] = False, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Exporting study {uuid}", extra={"user": current_user.id}) uuid_sanitized = sanitize_uuid(uuid) @@ -432,7 +432,7 @@ def delete_study( uuid: str, children: bool = False, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Deleting study {uuid}", extra={"user": current_user.id}) uuid_sanitized = sanitize_uuid(uuid) @@ -452,7 +452,7 @@ def import_output( uuid: str, output: bytes = File(...), current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Importing output for study {uuid}", extra={"user": current_user.id}, @@ -474,7 +474,7 @@ def change_owner( uuid: str, user_id: int, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Changing owner to {user_id} for study {uuid}", extra={"user": current_user.id}, @@ -494,7 +494,7 @@ def add_group( uuid: str, group_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Adding group {group_id} to study {uuid}", extra={"user": current_user.id}, @@ -515,7 +515,7 @@ def remove_group( uuid: str, group_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Removing group {group_id} to study {uuid}", extra={"user": current_user.id}, @@ -537,7 +537,7 @@ def set_public_mode( uuid: str, mode: PublicMode, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Setting public mode to {mode} for study {uuid}", extra={"user": current_user.id}, @@ -552,11 +552,11 @@ def set_public_mode( "/studies/_versions", tags=[APITag.study_management], summary="Show available study versions", - response_model=List[str], + response_model=t.List[str], ) def get_study_versions( current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: params = RequestParameters(user=current_user) logger.info("Fetching version list") return StudyService.get_studies_versions(params=params) @@ -570,7 +570,7 @@ def get_study_versions( def get_study_metadata( uuid: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Fetching study {uuid} metadata", extra={"user": current_user.id}) params = RequestParameters(user=current_user) study_metadata = study_service.get_study_information(uuid, params) @@ -586,7 +586,7 @@ def update_study_metadata( uuid: str, study_metadata_patch: StudyMetadataPatchDTO, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Updating metadata for study {uuid}", extra={"user": current_user.id}, @@ -604,7 +604,7 @@ def output_variables_information( study_id: str, output_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(study_id) output_id = sanitize_uuid(output_id) logger.info(f"Fetching whole output of the simulation {output_id} for study {study_id}") @@ -624,7 +624,7 @@ def output_export( study_id: str, output_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(study_id) output_id = sanitize_uuid(output_id) logger.info(f"Fetching whole output of the simulation {output_id} for study {study_id}") @@ -648,7 +648,7 @@ def output_download( use_task: bool = False, tmp_export_file: Path = Depends(ftm.request_tmp_file), current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(study_id) output_id = sanitize_uuid(output_id) logger.info( @@ -702,7 +702,7 @@ def archive_output( study_id: str, output_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(study_id) output_id = sanitize_uuid(output_id) logger.info( @@ -727,7 +727,7 @@ def unarchive_output( study_id: str, output_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: study_id = sanitize_uuid(study_id) output_id = sanitize_uuid(output_id) logger.info( @@ -748,12 +748,12 @@ def unarchive_output( "/studies/{study_id}/outputs", summary="Get global information about a study simulation result", tags=[APITag.study_outputs], - response_model=List[StudySimResultDTO], + response_model=t.List[StudySimResultDTO], ) def sim_result( study_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Fetching output list for study {study_id}", extra={"user": current_user.id}, @@ -773,7 +773,7 @@ def set_sim_reference( output_id: str, status: bool = True, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( f"Setting output {output_id} as reference simulation for study {study_id}", extra={"user": current_user.id}, @@ -792,7 +792,7 @@ def set_sim_reference( def archive_study( study_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Archiving study {study_id}", extra={"user": current_user.id}) study_id = sanitize_uuid(study_id) params = RequestParameters(user=current_user) @@ -806,7 +806,7 @@ def archive_study( def unarchive_study( study_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info(f"Unarchiving study {study_id}", extra={"user": current_user.id}) study_id = sanitize_uuid(study_id) params = RequestParameters(user=current_user) @@ -819,7 +819,7 @@ def unarchive_study( ) def invalidate_study_listing_cache( current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> t.Any: logger.info( "Invalidating the study listing cache", extra={"user": current_user.id}, From fd0fa3ed3add5bb17827e6984cfe546c7f83886c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 10:03:48 +0100 Subject: [PATCH 10/13] chore(study-search): add a todo to use `with_polymorphic` I think we should use a `entity = with_polymorphic(Study, "*")` to make sure RawStudy and VariantStudy fields are also fetched. --- antarest/study/repository.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 00d65a79b9..e4646e1546 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -149,6 +149,9 @@ def refresh(self, metadata: Study) -> None: def get(self, id: str) -> t.Optional[Study]: """Get the study by ID or return `None` if not found in database.""" + # todo: I think we should use a `entity = with_polymorphic(Study, "*")` + # to make sure RawStudy and VariantStudy fields are also fetched. + # see: antarest.study.service.StudyService.delete_study # When we fetch a study, we also need to fetch the associated owner and groups # to check the permissions of the current user efficiently. study: Study = ( @@ -163,6 +166,9 @@ def get(self, id: str) -> t.Optional[Study]: def one(self, study_id: str) -> Study: """Get the study by ID or raise `sqlalchemy.exc.NoResultFound` if not found in database.""" + # todo: I think we should use a `entity = with_polymorphic(Study, "*")` + # to make sure RawStudy and VariantStudy fields are also fetched. + # see: antarest.study.service.StudyService.delete_study # When we fetch a study, we also need to fetch the associated owner and groups # to check the permissions of the current user efficiently. study: Study = ( From 1adb09a52d731342cca3d1cc1ac436fc7c1003be Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 10:52:18 +0100 Subject: [PATCH 11/13] feat(study-search): improve the get_studies endpoint to better check `versions` and `users` parameters --- antarest/study/web/studies_blueprint.py | 21 +++++++++++-------- .../studies_blueprint/test_get_studies.py | 8 ++++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index daec898d67..95bb760ea5 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -76,8 +76,16 @@ def get_studies( managed: t.Optional[bool] = Query(None, description="Filter studies based on their management status."), archived: t.Optional[bool] = Query(None, description="Filter studies based on their archive status."), variant: t.Optional[bool] = Query(None, description="Filter studies based on their variant status."), - versions: str = Query("", description="Comma-separated list of versions for filtering."), - users: str = Query("", description="Comma-separated list of group IDs for filtering."), + versions: str = Query( + "", + description="Comma-separated list of versions for filtering.", + regex=r"^\s*(?:\d+\s*(?:,\s*\d+\s*)*)?$", + ), + users: str = Query( + "", + description="Comma-separated list of user IDs for filtering.", + regex=r"^\s*(?:\d+\s*(?:,\s*\d+\s*)*)?$", + ), groups: str = Query("", description="Comma-separated list of group IDs for filtering."), tags: str = Query("", description="Comma-separated list of tags for filtering."), study_ids: str = Query( @@ -115,7 +123,7 @@ def get_studies( - `archived`: Filter studies based on their archive status. - `variant`: Filter studies based on their variant status. - `versions`: Comma-separated list of versions for filtering. - - `users`: Comma-separated list of group IDs for filtering. + - `users`: Comma-separated list of user IDs for filtering. - `groups`: Comma-separated list of group IDs for filtering. - `tags`: Comma-separated list of tags for filtering. - `studyIds`: Comma-separated list of study IDs for filtering. @@ -134,12 +142,7 @@ def get_studies( logger.info("Fetching for matching studies", extra={"user": current_user.id}) params = RequestParameters(user=current_user) - # todo: there must be another way to do this - # for instance by using a pydantic model with a custom validator - try: - user_list = [int(v) for v in _split_comma_separated_values(users)] - except ValueError: - raise HTTPException(status_code=422, detail="'users' must be a list of integers") from None + user_list = [int(v) for v in _split_comma_separated_values(users)] study_filter = StudyFilter( name=name, diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 6026ecaa40..b134406e50 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -751,11 +751,17 @@ def test_get_studies__invalid_parameters( description = res.json()["description"] assert re.search(r"could not be parsed to a boolean", description), f"{description=}" + # Invalid `versions` parameter (not a list of integers) + res = client.get(STUDIES_URL, headers=headers, params={"versions": "invalid"}) + assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() + description = res.json()["description"] + assert re.search(r"string does not match regex", description), f"{description=}" + # Invalid `users` parameter (not a list of integers) res = client.get(STUDIES_URL, headers=headers, params={"users": "invalid"}) assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() description = res.json()["description"] - assert re.search(r"must be a list of integers", description), f"{description=}" + assert re.search(r"string does not match regex", description), f"{description=}" # Invalid `exists` parameter (not a boolean) res = client.get(STUDIES_URL, headers=headers, params={"exists": "invalid"}) From 911dbd84642e9220373233353f83ec1c18c3d2be Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Jan 2024 11:44:14 +0100 Subject: [PATCH 12/13] fix(study-search): avoid polynomial runtime due to backtracking in regex (SonarCloud issue) --- antarest/study/web/studies_blueprint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 95bb760ea5..275d9410b3 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -1,7 +1,6 @@ import collections import io import logging -import re import typing as t from http import HTTPStatus from pathlib import Path @@ -38,8 +37,9 @@ def _split_comma_separated_values(value: str, *, default: t.Sequence[str] = ()) -> t.Sequence[str]: """Split a comma-separated list of values into an ordered set of strings.""" - value = value.strip() - value_list = re.split(r"\s*,\s*", value) if value else default + value_list = value.split(",") if value else default + # drop whitespace around values + value_list = map(str.strip, value_list) # remove duplicates and preserve order (to have a deterministic result for unit tests). return list(collections.OrderedDict.fromkeys(value_list)) From 1429686e2b44c5f69f62b86ae8df4f67c7927622 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 26 Jan 2024 12:44:45 +0100 Subject: [PATCH 13/13] style(study-search): correct issue with mypy linter --- antarest/study/web/studies_blueprint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 275d9410b3..e4fcbe100e 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -37,11 +37,11 @@ def _split_comma_separated_values(value: str, *, default: t.Sequence[str] = ()) -> t.Sequence[str]: """Split a comma-separated list of values into an ordered set of strings.""" - value_list = value.split(",") if value else default + values = value.split(",") if value else default # drop whitespace around values - value_list = map(str.strip, value_list) + values = [v.strip() for v in values] # remove duplicates and preserve order (to have a deterministic result for unit tests). - return list(collections.OrderedDict.fromkeys(value_list)) + return list(collections.OrderedDict.fromkeys(values)) def create_study_routes(study_service: StudyService, ftm: FileTransferManager, config: Config) -> APIRouter: