From 669ce11361cd843e8fe80026ee3a8613c99ecca2 Mon Sep 17 00:00:00 2001 From: Sylvain Leclerc Date: Wed, 11 Sep 2024 13:18:54 +0200 Subject: [PATCH] fix(standalone): fix api path when embedding front-end (#2136) Distinguish root_path and api_prefix. root_path is to be used when a proxy prepends a prefix for the client, api_prefix is to be used when we want our server itself to prepend a prefix. Signed-off-by: Sylvain Leclerc --- .github/workflows/main.yml | 2 +- antarest/core/application.py | 48 ++ antarest/core/config.py | 2 + antarest/core/filetransfer/main.py | 9 +- antarest/core/maintenance/main.py | 9 +- antarest/core/tasks/main.py | 9 +- antarest/eventbus/main.py | 9 +- antarest/eventbus/web.py | 7 +- antarest/front.py | 139 ++++ antarest/gui.py | 2 +- antarest/launcher/main.py | 9 +- antarest/login/ldap.py | 1 - antarest/login/main.py | 15 +- antarest/main.py | 110 +-- antarest/matrixstore/main.py | 11 +- antarest/singleton_services.py | 4 +- antarest/study/business/all_optional_meta.py | 4 +- antarest/study/main.py | 18 +- antarest/tools/lib.py | 2 +- antarest/utils.py | 45 +- requirements-test.txt | 4 +- resources/application.yaml | 19 +- resources/deploy/config.prod.yaml | 4 + resources/deploy/config.yaml | 3 +- resources/templates/.placeholder | 0 scripts/build-front.sh | 1 - .../test_filesystem_endpoints.py | 26 +- .../filesystem_blueprint/test_model.py | 13 +- tests/launcher/test_web.py | 8 +- tests/login/test_web.py | 6 +- tests/matrixstore/test_web.py | 7 +- tests/storage/integration/conftest.py | 2 +- tests/storage/integration/test_STA_mini.py | 56 +- tests/storage/integration/test_exporter.py | 7 +- tests/storage/web/test_studies_bp.py | 269 +----- tests/test_front.py | 111 +++ tests/variantstudy/test_command_factory.py | 766 +++++++++--------- .../components/common/MatrixInput/index.tsx | 2 +- .../components/common/page/SimpleContent.tsx | 1 + 39 files changed, 909 insertions(+), 851 deletions(-) create mode 100644 antarest/core/application.py create mode 100644 antarest/front.py delete mode 100644 resources/templates/.placeholder create mode 100644 tests/test_front.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a7b9ad212..91523a511d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,7 +53,7 @@ jobs: pip install -r requirements-dev.txt - name: Test with pytest run: | - pytest --cov antarest --cov-report xml + pytest --cov antarest --cov-report xml -n auto - name: Archive code coverage results if: matrix.os == 'ubuntu-20.04' uses: actions/upload-artifact@v4 diff --git a/antarest/core/application.py b/antarest/core/application.py new file mode 100644 index 0000000000..3f09bd4102 --- /dev/null +++ b/antarest/core/application.py @@ -0,0 +1,48 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +from dataclasses import dataclass +from typing import Optional + +from fastapi import APIRouter, FastAPI + + +@dataclass(frozen=True) +class AppBuildContext: + """ + Base elements of the application, for use at construction time: + - app: the actual fastapi application, where middlewares, exception handlers, etc. may be added + - api_root: the route under which all API and WS endpoints must be registered + + API routes should not be added straight to app, but under api_root instead, + so that they are correctly prefixed if needed (/api for standalone mode). + + Warning: the inclusion of api_root must happen AFTER all subroutes + have been registered, hence the build method. + """ + + app: FastAPI + api_root: APIRouter + + def build(self) -> FastAPI: + """ + Finalizes the app construction by including the API route. + Must be performed AFTER all subroutes have been added. + """ + self.app.include_router(self.api_root) + return self.app + + +def create_app_ctxt(app: FastAPI, api_root: Optional[APIRouter] = None) -> AppBuildContext: + if not api_root: + api_root = APIRouter() + return AppBuildContext(app, api_root) diff --git a/antarest/core/config.py b/antarest/core/config.py index 3378444d92..4b85331d6a 100644 --- a/antarest/core/config.py +++ b/antarest/core/config.py @@ -593,6 +593,7 @@ class Config: cache: CacheConfig = CacheConfig() tasks: TaskConfig = TaskConfig() root_path: str = "" + api_prefix: str = "" @classmethod def from_dict(cls, data: JSON) -> "Config": @@ -611,6 +612,7 @@ def from_dict(cls, data: JSON) -> "Config": cache=CacheConfig.from_dict(data["cache"]) if "cache" in data else defaults.cache, tasks=TaskConfig.from_dict(data["tasks"]) if "tasks" in data else defaults.tasks, root_path=data.get("root_path", defaults.root_path), + api_prefix=data.get("api_prefix", defaults.api_prefix), ) @classmethod diff --git a/antarest/core/filetransfer/main.py b/antarest/core/filetransfer/main.py index cbf732ef22..3583dc5701 100644 --- a/antarest/core/filetransfer/main.py +++ b/antarest/core/filetransfer/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.filetransfer.repository import FileDownloadRepository from antarest.core.filetransfer.service import FileTransferManager @@ -22,10 +23,10 @@ def build_filetransfer_service( - application: Optional[FastAPI], event_bus: IEventBus, config: Config + app_ctxt: Optional[AppBuildContext], event_bus: IEventBus, config: Config ) -> FileTransferManager: ftm = FileTransferManager(repository=FileDownloadRepository(), event_bus=event_bus, config=config) - if application: - application.include_router(create_file_transfer_api(ftm, config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_file_transfer_api(ftm, config)) return ftm diff --git a/antarest/core/maintenance/main.py b/antarest/core/maintenance/main.py index b129d85691..8717150d07 100644 --- a/antarest/core/maintenance/main.py +++ b/antarest/core/maintenance/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.interfaces.cache import ICache from antarest.core.interfaces.eventbus import DummyEventBusService, IEventBus @@ -23,7 +24,7 @@ def build_maintenance_manager( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, cache: ICache, event_bus: IEventBus = DummyEventBusService(), @@ -31,7 +32,7 @@ def build_maintenance_manager( repository = MaintenanceRepository() service = MaintenanceService(config, repository, event_bus, cache) - if application: - application.include_router(create_maintenance_api(service, config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_maintenance_api(service, config)) return service diff --git a/antarest/core/tasks/main.py b/antarest/core/tasks/main.py index ae3d7dafb8..74685ba836 100644 --- a/antarest/core/tasks/main.py +++ b/antarest/core/tasks/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.interfaces.eventbus import DummyEventBusService, IEventBus from antarest.core.tasks.repository import TaskJobRepository @@ -22,14 +23,14 @@ def build_taskjob_manager( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, event_bus: IEventBus = DummyEventBusService(), ) -> ITaskService: repository = TaskJobRepository() service = TaskJobService(config, repository, event_bus) - if application: - application.include_router(create_tasks_api(service, config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_tasks_api(service, config)) return service diff --git a/antarest/eventbus/main.py b/antarest/eventbus/main.py index 7c53f5cbce..6ccf56e644 100644 --- a/antarest/eventbus/main.py +++ b/antarest/eventbus/main.py @@ -12,9 +12,10 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from redis import Redis +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.eventbus.business.local_eventbus import LocalEventBus from antarest.eventbus.business.redis_eventbus import RedisEventBus @@ -23,7 +24,7 @@ def build_eventbus( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, autostart: bool = True, redis_client: Optional[Redis] = None, # type: ignore @@ -33,6 +34,6 @@ def build_eventbus( autostart, ) - if application: - configure_websockets(application, config, eventbus) + if app_ctxt: + configure_websockets(app_ctxt, config, eventbus) return eventbus diff --git a/antarest/eventbus/web.py b/antarest/eventbus/web.py index c5d635cc99..ba363db6c4 100644 --- a/antarest/eventbus/web.py +++ b/antarest/eventbus/web.py @@ -17,10 +17,11 @@ from http import HTTPStatus from typing import List, Optional -from fastapi import Depends, FastAPI, HTTPException, Query +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query from pydantic import BaseModel from starlette.websockets import WebSocket, WebSocketDisconnect +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.interfaces.eventbus import Event, IEventBus from antarest.core.jwt import DEFAULT_ADMIN_USER, JWTUser @@ -91,7 +92,7 @@ async def broadcast(self, message: str, permissions: PermissionInfo, channel: st await connection.websocket.send_text(message) -def configure_websockets(application: FastAPI, config: Config, event_bus: IEventBus) -> None: +def configure_websockets(app_ctxt: AppBuildContext, config: Config, event_bus: IEventBus) -> None: manager = ConnectionManager() async def send_event_to_ws(event: Event) -> None: @@ -100,7 +101,7 @@ async def send_event_to_ws(event: Event) -> None: del event_data["channel"] await manager.broadcast(json.dumps(event_data), event.permissions, event.channel) - @application.websocket("/ws") + @app_ctxt.api_root.websocket("/ws") async def connect( websocket: WebSocket, token: str = Query(...), diff --git a/antarest/front.py b/antarest/front.py new file mode 100644 index 0000000000..8de0f05e82 --- /dev/null +++ b/antarest/front.py @@ -0,0 +1,139 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. +""" +This module contains the logic necessary to serve both +the front-end application and the backend HTTP application. + +This includes: + - serving static frontend files + - redirecting "not found" requests to home, which itself redirects to index.html + - providing the endpoint /config.json, which the front-end uses to know + what are the API and websocket prefixes +""" + +import re +from pathlib import Path +from typing import Any, Optional, Sequence + +from fastapi import FastAPI +from pydantic import BaseModel +from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import FileResponse +from starlette.staticfiles import StaticFiles +from starlette.types import ASGIApp + +from antarest.core.utils.string import to_camel_case + + +class RedirectMiddleware(BaseHTTPMiddleware): + """ + Middleware that rewrites the URL path to "/" for incoming requests + that do not match the known end points. This is useful for redirecting requests + to the main page of a ReactJS application when the user refreshes the browser. + """ + + def __init__( + self, + app: ASGIApp, + dispatch: Optional[DispatchFunction] = None, + route_paths: Sequence[str] = (), + ) -> None: + """ + Initializes an instance of the URLRewriterMiddleware. + + Args: + app: The ASGI application to which the middleware is applied. + dispatch: The dispatch function to use. + route_paths: The known route paths of the application. + Requests that do not match any of these paths will be rewritten to the root path. + + Note: + The `route_paths` should contain all the known endpoints of the application. + """ + dispatch = self.dispatch if dispatch is None else dispatch + super().__init__(app, dispatch) + self.known_prefixes = {re.findall(r"/(?:(?!/).)*", p)[0] for p in route_paths if p != "/"} + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Any: + """ + Intercepts the incoming request and rewrites the URL path if necessary. + Passes the modified or original request to the next middleware or endpoint handler. + """ + url_path = request.scope["path"] + if url_path in {"", "/"}: + pass + elif not any(url_path.startswith(ep) for ep in self.known_prefixes): + request.scope["path"] = "/" + return await call_next(request) + + +class BackEndConfig(BaseModel): + """ + Configuration about backend URLs served to the frontend. + """ + + rest_endpoint: str + ws_endpoint: str + + class Config: + populate_by_name = True + alias_generator = to_camel_case + + +def create_backend_config(api_prefix: str) -> BackEndConfig: + if not api_prefix.startswith("/"): + api_prefix = "/" + api_prefix + return BackEndConfig(rest_endpoint=f"{api_prefix}", ws_endpoint=f"{api_prefix}/ws") + + +def add_front_app(application: FastAPI, resources_dir: Path, api_prefix: str) -> None: + """ + This functions adds the logic necessary to serve both + the front-end application and the backend HTTP application. + + This includes: + - serving static frontend files + - redirecting "not found" requests to home, which itself redirects to index.html + - providing the endpoint /config.json, which the front-end uses to know + what are the API and websocket prefixes + """ + backend_config = create_backend_config(api_prefix) + + front_app_dir = resources_dir / "webapp" + + # Serve front-end files + application.mount( + "/static", + StaticFiles(directory=front_app_dir), + name="static", + ) + + # Redirect home to index.html + @application.get("/", include_in_schema=False) + def home(request: Request) -> Any: + return FileResponse(front_app_dir / "index.html", 200) + + # Serve config for the front-end at /config.json + @application.get("/config.json", include_in_schema=False) + def get_api_paths_config(request: Request) -> BackEndConfig: + return backend_config + + # When the web application is running in Desktop mode, the ReactJS web app + # is served at the `/static` entry point. Any requests that are not API + # requests should be redirected to the `index.html` file, which will handle + # the route provided by the URL. + route_paths = [r.path for r in application.routes] # type: ignore + application.add_middleware( + RedirectMiddleware, + route_paths=route_paths, + ) diff --git a/antarest/gui.py b/antarest/gui.py index 12e2761c6c..f36904a060 100644 --- a/antarest/gui.py +++ b/antarest/gui.py @@ -18,7 +18,7 @@ from multiprocessing import Process from pathlib import Path -import httpx # TODO SL :check its ok on windows +import httpx import uvicorn from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction, QApplication, QMenu, QSystemTrayIcon diff --git a/antarest/launcher/main.py b/antarest/launcher/main.py index 3370936f54..1916b9607b 100644 --- a/antarest/launcher/main.py +++ b/antarest/launcher/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.filetransfer.service import FileTransferManager from antarest.core.interfaces.cache import ICache @@ -26,7 +27,7 @@ def build_launcher( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, study_service: StudyService, file_transfer_manager: FileTransferManager, @@ -49,7 +50,7 @@ def build_launcher( cache=cache, ) - if service_launcher and application: - application.include_router(create_launcher_api(service_launcher, config)) + if service_launcher and app_ctxt: + app_ctxt.api_root.include_router(create_launcher_api(service_launcher, config)) return service_launcher diff --git a/antarest/login/ldap.py b/antarest/login/ldap.py index 4333049ab2..1635efe09d 100644 --- a/antarest/login/ldap.py +++ b/antarest/login/ldap.py @@ -14,7 +14,6 @@ from dataclasses import dataclass from typing import Dict, List, Optional -# TODO SL: check this works on windows import httpx from antarest.core.config import Config diff --git a/antarest/login/main.py b/antarest/login/main.py index f39c5c36dd..09fac37ee5 100644 --- a/antarest/login/main.py +++ b/antarest/login/main.py @@ -14,10 +14,11 @@ from http import HTTPStatus from typing import Any, Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from starlette.requests import Request from starlette.responses import JSONResponse +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.interfaces.eventbus import DummyEventBusService, IEventBus from antarest.core.utils.fastapi_sqlalchemy import db @@ -30,7 +31,7 @@ def build_login( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, service: Optional[LoginService] = None, event_bus: IEventBus = DummyEventBusService(), @@ -39,7 +40,7 @@ def build_login( Login module linking dependency Args: - application: flask application + app_ctxt: application config: server configuration service: used by testing to inject mock. Let None to use true instantiation event_bus: used by testing to inject mock. Let None to use true instantiation @@ -66,9 +67,9 @@ def build_login( event_bus=event_bus, ) - if application: + if app_ctxt: - @application.exception_handler(AuthJWTException) + @app_ctxt.app.exception_handler(AuthJWTException) def authjwt_exception_handler(request: Request, exc: AuthJWTException) -> Any: return JSONResponse( status_code=HTTPStatus.UNAUTHORIZED, @@ -83,6 +84,6 @@ def check_if_token_is_revoked(decrypted_token: Any) -> bool: with db(): return token_type == "bots" and service is not None and not service.exists_bot(user_id) - if application: - application.include_router(create_login_api(service, config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_login_api(service, config)) return service diff --git a/antarest/main.py b/antarest/main.py index 93bbc50bf0..b1fd1480b6 100644 --- a/antarest/main.py +++ b/antarest/main.py @@ -13,29 +13,25 @@ import argparse import copy import logging -import re from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, AsyncGenerator, Dict, Optional, Sequence, Tuple, cast +from typing import Any, AsyncGenerator, Dict, Optional, Tuple, cast import pydantic import uvicorn import uvicorn.config -from fastapi import FastAPI, HTTPException +from fastapi import APIRouter, FastAPI, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from ratelimit import RateLimitMiddleware # type: ignore from ratelimit.backends.redis import RedisBackend # type: ignore from ratelimit.backends.simple import MemoryBackend # type: ignore -from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction, RequestResponseEndpoint from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.staticfiles import StaticFiles -from starlette.templating import Jinja2Templates -from starlette.types import ASGIApp from antarest import __version__ +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.core_blueprint import create_utils_routes from antarest.core.filesystem_blueprint import create_file_system_blueprint @@ -47,6 +43,7 @@ from antarest.core.utils.utils import get_local_path from antarest.core.utils.web import tags_metadata from antarest.fastapi_jwt_auth import AuthJWT +from antarest.front import add_front_app from antarest.login.auth import Auth, JwtSettings from antarest.login.model import init_admin_user from antarest.matrixstore.matrix_garbage_collector import MatrixGarbageCollector @@ -190,55 +187,6 @@ def parse_arguments() -> argparse.Namespace: return parser.parse_args() -class URLRewriterMiddleware(BaseHTTPMiddleware): - """ - Middleware that rewrites the URL path to "/" (root path) for incoming requests - that do not match the known end points. This is useful for redirecting requests - to the main page of a ReactJS application when the user refreshes the browser. - """ - - def __init__( - self, - app: ASGIApp, - dispatch: Optional[DispatchFunction] = None, - root_path: str = "", - route_paths: Sequence[str] = (), - ) -> None: - """ - Initializes an instance of the URLRewriterMiddleware. - - Args: - app: The ASGI application to which the middleware is applied. - dispatch: The dispatch function to use. - root_path: The root path of the application. - The URL path will be rewritten relative to this root path. - route_paths: The known route paths of the application. - Requests that do not match any of these paths will be rewritten to the root path. - - Note: - The `root_path` can be set to a specific component of the URL path, such as "api". - The `route_paths` should contain all the known endpoints of the application. - """ - dispatch = self.dispatch if dispatch is None else dispatch - super().__init__(app, dispatch) - self.root_path = f"/{root_path}" if root_path else "" - self.known_prefixes = {re.findall(r"/(?:(?!/).)*", p)[0] for p in route_paths if p != "/"} - - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Any: - """ - Intercepts the incoming request and rewrites the URL path if necessary. - Passes the modified or original request to the next middleware or endpoint handler. - """ - url_path = request.scope["path"] - if url_path in {"", "/"}: - pass - elif self.root_path and url_path.startswith(self.root_path): - request.scope["path"] = url_path[len(self.root_path) :] - elif not any(url_path.startswith(ep) for ep in self.known_prefixes): - request.scope["path"] = "/" - return await call_next(request) - - def fastapi_app( config_file: Path, resource_path: Optional[Path] = None, @@ -267,8 +215,13 @@ async def set_default_executor(app: FastAPI) -> AsyncGenerator[None, None]: root_path=config.root_path, openapi_tags=tags_metadata, lifespan=set_default_executor, + openapi_url=f"{config.api_prefix}/openapi.json", ) + api_root = APIRouter(prefix=config.api_prefix) + + app_ctxt = AppBuildContext(application, api_root) + # Database engine = init_db_engine(config_file, config, auto_upgrade_db) application.add_middleware(DBSessionMiddleware, custom_engine=engine, session_args=SESSION_ARGS) @@ -279,24 +232,6 @@ async def set_default_executor(app: FastAPI) -> AsyncGenerator[None, None]: application.add_middleware(LoggingMiddleware) - if mount_front: - application.mount( - "/static", - StaticFiles(directory=str(res / "webapp")), - name="static", - ) - templates = Jinja2Templates(directory=str(res / "templates")) - - @application.get("/", include_in_schema=False) - def home(request: Request) -> Any: - return templates.TemplateResponse("index.html", {"request": request}) - - else: - # noinspection PyUnusedLocal - @application.get("/", include_in_schema=False) - def home(request: Request) -> Any: - return "" - # TODO move that elsewhere @AuthJWT.load_config # type: ignore def get_config() -> JwtSettings: @@ -315,8 +250,8 @@ def get_config() -> JwtSettings: allow_methods=["*"], allow_headers=["*"], ) - application.include_router(create_utils_routes(config)) - application.include_router(create_file_system_blueprint(config)) + api_root.include_router(create_utils_routes(config)) + api_root.include_router(create_file_system_blueprint(config)) # noinspection PyUnusedLocal @application.exception_handler(HTTPException) @@ -426,19 +361,9 @@ def handle_all_exception(request: Request, exc: Exception) -> Any: ) init_admin_user(engine=engine, session_args=SESSION_ARGS, admin_password=config.security.admin_pwd) - services = create_services(config, application) + services = create_services(config, app_ctxt) - if mount_front: - # When the web application is running in Desktop mode, the ReactJS web app - # is served at the `/static` entry point. Any requests that are not API - # requests should be redirected to the `index.html` file, which will handle - # the route provided by the URL. - route_paths = [r.path for r in application.routes] # type: ignore - application.add_middleware( - URLRewriterMiddleware, - root_path=application.root_path, - route_paths=route_paths, - ) + application.include_router(api_root) if config.server.services and Module.WATCHER.value in config.server.services: watcher = cast(Watcher, services["watcher"]) @@ -453,6 +378,15 @@ def handle_all_exception(request: Request, exc: Exception) -> Any: auto_archiver.start() customize_openapi(application) + + if mount_front: + add_front_app(application, res, config.api_prefix) + else: + # noinspection PyUnusedLocal + @application.get("/", include_in_schema=False) + def home(request: Request) -> Any: + return "" + cancel_orphan_tasks(engine=engine, session_args=SESSION_ARGS) return application, services diff --git a/antarest/matrixstore/main.py b/antarest/matrixstore/main.py index eaec4f956a..d8eaf0390a 100644 --- a/antarest/matrixstore/main.py +++ b/antarest/matrixstore/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.filetransfer.service import FileTransferManager from antarest.core.tasks.service import ITaskService @@ -24,7 +25,7 @@ def build_matrix_service( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, file_transfer_manager: FileTransferManager, task_service: ITaskService, @@ -35,7 +36,7 @@ def build_matrix_service( Matrix module linking dependency Args: - application: flask application + app_ctxt: application config: server configuration file_transfer_manager: File transfer manager task_service: Task manager @@ -60,7 +61,7 @@ def build_matrix_service( config=config, ) - if application: - application.include_router(create_matrix_api(service, file_transfer_manager, config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_matrix_api(service, file_transfer_manager, config)) return service diff --git a/antarest/singleton_services.py b/antarest/singleton_services.py index 039c3d00b5..13395a439a 100644 --- a/antarest/singleton_services.py +++ b/antarest/singleton_services.py @@ -56,13 +56,13 @@ def _init(config_file: Path, services_list: List[Module]) -> Dict[Module, IServi services: Dict[Module, IService] = {} if Module.WATCHER in services_list: - watcher = create_watcher(config=config, application=None, study_service=study_service) + watcher = create_watcher(config=config, app_ctxt=None, study_service=study_service) services[Module.WATCHER] = watcher if Module.MATRIX_GC in services_list: matrix_gc = create_matrix_gc( config=config, - application=None, + app_ctxt=None, study_service=study_service, matrix_service=matrix_service, ) diff --git a/antarest/study/business/all_optional_meta.py b/antarest/study/business/all_optional_meta.py index c580e8fa00..7a8440226b 100644 --- a/antarest/study/business/all_optional_meta.py +++ b/antarest/study/business/all_optional_meta.py @@ -17,8 +17,10 @@ from antarest.core.utils.string import to_camel_case +ModelClass = t.TypeVar("ModelClass", bound=BaseModel) -def all_optional_model(model: t.Type[BaseModel]) -> t.Type[BaseModel]: + +def all_optional_model(model: t.Type[ModelClass]) -> t.Type[ModelClass]: """ This decorator can be used to make all fields of a pydantic model optionals. diff --git a/antarest/study/main.py b/antarest/study/main.py index e80a353493..1efa9cb00b 100644 --- a/antarest/study/main.py +++ b/antarest/study/main.py @@ -12,8 +12,9 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from antarest.core.application import AppBuildContext from antarest.core.config import Config from antarest.core.filetransfer.service import FileTransferManager from antarest.core.interfaces.cache import ICache @@ -39,7 +40,7 @@ def build_study_service( - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], config: Config, user_service: LoginService, matrix_service: ISimpleMatrixService, @@ -124,16 +125,17 @@ def build_study_service( config=config, ) - if application: - application.include_router(create_study_routes(study_service, file_transfer_manager, config)) - application.include_router(create_raw_study_routes(study_service, config)) - application.include_router(create_study_data_routes(study_service, config)) - application.include_router( + if app_ctxt: + api_root = app_ctxt.api_root + api_root.include_router(create_study_routes(study_service, file_transfer_manager, config)) + api_root.include_router(create_raw_study_routes(study_service, config)) + api_root.include_router(create_study_data_routes(study_service, config)) + api_root.include_router( create_study_variant_routes( study_service=study_service, config=config, ) ) - application.include_router(create_xpansion_routes(study_service, config)) + api_root.include_router(create_xpansion_routes(study_service, config)) return study_service diff --git a/antarest/tools/lib.py b/antarest/tools/lib.py index dbd58944e6..e2e876f723 100644 --- a/antarest/tools/lib.py +++ b/antarest/tools/lib.py @@ -20,7 +20,7 @@ from zipfile import ZipFile import numpy as np -from httpx import Client # TODO SL: check it work on windows +from httpx import Client from antarest.core.cache.business.local_chache import LocalCache from antarest.core.config import CacheConfig diff --git a/antarest/utils.py b/antarest/utils.py index 4ae8bf3fe7..1f7f4b7594 100644 --- a/antarest/utils.py +++ b/antarest/utils.py @@ -16,7 +16,7 @@ from typing import Any, Dict, Mapping, Optional, Tuple import redis -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from ratelimit import RateLimitMiddleware # type: ignore from ratelimit.backends.redis import RedisBackend # type: ignore from ratelimit.backends.simple import MemoryBackend # type: ignore @@ -24,6 +24,7 @@ from sqlalchemy.engine.base import Engine # type: ignore from sqlalchemy.pool import NullPool # type: ignore +from antarest.core.application import AppBuildContext from antarest.core.cache.main import build_cache from antarest.core.config import Config from antarest.core.filetransfer.main import build_filetransfer_service @@ -109,24 +110,24 @@ def init_db_engine( return engine -def create_event_bus(application: Optional[FastAPI], config: Config) -> Tuple[IEventBus, Optional[redis.Redis]]: # type: ignore +def create_event_bus(app_ctxt: Optional[AppBuildContext], config: Config) -> Tuple[IEventBus, Optional[redis.Redis]]: # type: ignore redis_client = new_redis_instance(config.redis) if config.redis is not None else None return ( - build_eventbus(application, config, True, redis_client), + build_eventbus(app_ctxt, config, True, redis_client), redis_client, ) def create_core_services( - application: Optional[FastAPI], config: Config + app_ctxt: Optional[AppBuildContext], config: Config ) -> Tuple[ICache, IEventBus, ITaskService, FileTransferManager, LoginService, MatrixService, StudyService,]: - event_bus, redis_client = create_event_bus(application, config) + event_bus, redis_client = create_event_bus(app_ctxt, config) cache = build_cache(config=config, redis_client=redis_client) - filetransfer_service = build_filetransfer_service(application, event_bus, config) - task_service = build_taskjob_manager(application, config, event_bus) - login_service = build_login(application, config, event_bus=event_bus) + filetransfer_service = build_filetransfer_service(app_ctxt, event_bus, config) + task_service = build_taskjob_manager(app_ctxt, config, event_bus) + login_service = build_login(app_ctxt, config, event_bus=event_bus) matrix_service = build_matrix_service( - application, + app_ctxt, config=config, file_transfer_manager=filetransfer_service, task_service=task_service, @@ -134,7 +135,7 @@ def create_core_services( service=None, ) study_service = build_study_service( - application, + app_ctxt, config, matrix_service=matrix_service, cache=cache, @@ -156,7 +157,7 @@ def create_core_services( def create_watcher( config: Config, - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], study_service: Optional[StudyService] = None, ) -> Watcher: if study_service: @@ -166,22 +167,22 @@ def create_watcher( task_service=study_service.task_service, ) else: - _, _, task_service, _, _, _, study_service = create_core_services(application, config) + _, _, task_service, _, _, _, study_service = create_core_services(app_ctxt, config) watcher = Watcher( config=config, study_service=study_service, task_service=task_service, ) - if application: - application.include_router(create_watcher_routes(watcher=watcher, config=config)) + if app_ctxt: + app_ctxt.api_root.include_router(create_watcher_routes(watcher=watcher, config=config)) return watcher def create_matrix_gc( config: Config, - application: Optional[FastAPI], + app_ctxt: Optional[AppBuildContext], study_service: Optional[StudyService] = None, matrix_service: Optional[MatrixService] = None, ) -> MatrixGarbageCollector: @@ -192,7 +193,7 @@ def create_matrix_gc( matrix_service=matrix_service, ) else: - _, _, _, _, _, matrix_service, study_service = create_core_services(application, config) + _, _, _, _, _, matrix_service, study_service = create_core_services(app_ctxt, config) return MatrixGarbageCollector( config=config, study_service=study_service, @@ -221,7 +222,7 @@ def create_simulator_worker( return SimulatorWorker(event_bus, matrix_service, config) -def create_services(config: Config, application: Optional[FastAPI], create_all: bool = False) -> Dict[str, Any]: +def create_services(config: Config, app_ctxt: Optional[AppBuildContext], create_all: bool = False) -> Dict[str, Any]: services: Dict[str, Any] = {} ( @@ -232,12 +233,12 @@ def create_services(config: Config, application: Optional[FastAPI], create_all: user_service, matrix_service, study_service, - ) = create_core_services(application, config) + ) = create_core_services(app_ctxt, config) - maintenance_service = build_maintenance_manager(application, config=config, cache=cache, event_bus=event_bus) + maintenance_service = build_maintenance_manager(app_ctxt, config=config, cache=cache, event_bus=event_bus) launcher = build_launcher( - application, + app_ctxt, config, study_service=study_service, event_bus=event_bus, @@ -246,13 +247,13 @@ def create_services(config: Config, application: Optional[FastAPI], create_all: cache=cache, ) - watcher = create_watcher(config=config, application=application, study_service=study_service) + watcher = create_watcher(config=config, app_ctxt=app_ctxt, study_service=study_service) services["watcher"] = watcher if config.server.services and Module.MATRIX_GC.value in config.server.services or create_all: matrix_garbage_collector = create_matrix_gc( config=config, - application=application, + app_ctxt=app_ctxt, study_service=study_service, matrix_service=matrix_service, ) diff --git a/requirements-test.txt b/requirements-test.txt index 9d58c1bb5b..e72650560c 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,9 @@ -r requirements.txt checksumdir~=1.2.0 -pytest~=6.2.5 +pytest~=8.3.0 +pytest-xdist~=3.6.0 pytest-cov~=4.0.0 +pytest-mock~=3.14.0 # In this version DataFrame conversion to Excel is done using 'xlsxwriter' library. # But Excel files reading is done using 'openpyxl' library, during testing only. diff --git a/resources/application.yaml b/resources/application.yaml index 6fbdb31f9f..c962d09015 100644 --- a/resources/application.yaml +++ b/resources/application.yaml @@ -26,7 +26,19 @@ launcher: 700: path/to/700 enable_nb_cores_detection: true -root_path: "api" +# See https://fastapi.tiangolo.com/advanced/behind-a-proxy/ +# root path is used when the API is served behind a proxy which +# adds a prefix for clients. +# It does NOT add any prefix to the URLs which fastapi serve. + +# root_path: "api" + + +# Uncomment to serve the API under /api prefix +# (used in standalone mode to emulate the effect of proxy servers +# used in production deployments). + +# api_prefix: "/api" server: worker_threadpool_size: 12 @@ -36,4 +48,7 @@ server: logging: level: INFO - logfile: ./tmp/antarest.log \ No newline at end of file + logfile: ./tmp/antarest.log + +# True to get sqlalchemy logs +debug: False diff --git a/resources/deploy/config.prod.yaml b/resources/deploy/config.prod.yaml index cf9087a2af..9d8d78d56e 100644 --- a/resources/deploy/config.prod.yaml +++ b/resources/deploy/config.prod.yaml @@ -66,6 +66,10 @@ launcher: debug: false +# See https://fastapi.tiangolo.com/advanced/behind-a-proxy/ +# root path is used when the API is served behind a proxy which +# adds a prefix for clients. +# It does NOT add any prefix to the URLs which fastapi serve. root_path: "api" #tasks: diff --git a/resources/deploy/config.yaml b/resources/deploy/config.yaml index 6f3fdf595f..08831198ce 100644 --- a/resources/deploy/config.yaml +++ b/resources/deploy/config.yaml @@ -46,7 +46,8 @@ launcher: debug: false -root_path: "api" +# Serve the API at /api +api_prefix: "/api" server: worker_threadpool_size: 12 diff --git a/resources/templates/.placeholder b/resources/templates/.placeholder deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/scripts/build-front.sh b/scripts/build-front.sh index 87c2db6a50..15c2e0e60f 100755 --- a/scripts/build-front.sh +++ b/scripts/build-front.sh @@ -11,4 +11,3 @@ npm run build -- --mode=desktop cd .. rm -fr resources/webapp cp -r ./webapp/dist/ resources/webapp -cp ./webapp/dist/index.html resources/templates/ diff --git a/tests/integration/filesystem_blueprint/test_filesystem_endpoints.py b/tests/integration/filesystem_blueprint/test_filesystem_endpoints.py index d1e344e73d..ac1e9a36ff 100644 --- a/tests/integration/filesystem_blueprint/test_filesystem_endpoints.py +++ b/tests/integration/filesystem_blueprint/test_filesystem_endpoints.py @@ -13,10 +13,10 @@ import datetime import operator import re -import shutil import typing as t from pathlib import Path +from pytest_mock import MockerFixture from starlette.testclient import TestClient from tests.integration.conftest import RESOURCES_DIR @@ -93,6 +93,7 @@ def test_lifecycle( client: TestClient, user_access_token: str, admin_access_token: str, + mocker: MockerFixture, ) -> None: """ Test the lifecycle of the filesystem endpoints. @@ -102,7 +103,7 @@ def test_lifecycle( caplog: pytest caplog fixture. client: test client (tests.integration.conftest.client_fixture). user_access_token: access token of a classic user (tests.integration.conftest.user_access_token_fixture). - admin_access_token: access token of an admin user (tests.integration.conftest.admin_access_token_fixture). + admin_access_token: access token of an admin user (tests.integration.conftestin_access_token_fixture). """ # NOTE: all the following paths are based on the configuration defined in the app_fixture. archive_dir = tmp_path / "archive_dir" @@ -165,26 +166,25 @@ def test_lifecycle( err_count += 1 # Known filesystem + mocker.patch("shutil.disk_usage", return_value=(100, 200, 300)) res = client.get("/v1/filesystem/ws", headers=user_headers) assert res.status_code == 200, res.json() actual = sorted(res.json(), key=operator.itemgetter("name")) - # Both mount point are in the same filesystem, which is the `tmp_path` filesystem - total_bytes, used_bytes, free_bytes = shutil.disk_usage(tmp_path) expected = [ { "name": "default", "path": str(default_workspace), - "total_bytes": total_bytes, - "used_bytes": used_bytes, - "free_bytes": free_bytes, + "total_bytes": 100, + "used_bytes": 200, + "free_bytes": 300, "message": AnyDiskUsagePercent(), }, { "name": "ext", "path": str(ext_workspace_path), - "total_bytes": total_bytes, - "used_bytes": used_bytes, - "free_bytes": free_bytes, + "total_bytes": 100, + "used_bytes": 200, + "free_bytes": 300, "message": AnyDiskUsagePercent(), }, ] @@ -206,9 +206,9 @@ def test_lifecycle( expected = { "name": "default", "path": str(default_workspace), - "total_bytes": total_bytes, - "used_bytes": used_bytes, - "free_bytes": free_bytes, + "total_bytes": 100, + "used_bytes": 200, + "free_bytes": 300, "message": AnyDiskUsagePercent(), } assert actual == expected diff --git a/tests/integration/filesystem_blueprint/test_model.py b/tests/integration/filesystem_blueprint/test_model.py index 4f184c4242..d26bdb02cb 100644 --- a/tests/integration/filesystem_blueprint/test_model.py +++ b/tests/integration/filesystem_blueprint/test_model.py @@ -16,6 +16,8 @@ import shutil from pathlib import Path +from pytest_mock import MockerFixture + from antarest.core.filesystem_blueprint import FileInfoDTO, FilesystemDTO, MountPointDTO @@ -63,15 +65,16 @@ def test_from_path__missing_file(self) -> None: assert dto.free_bytes == 0 assert dto.message.startswith("N/A:"), dto.message - def test_from_path__file(self, tmp_path: Path) -> None: + def test_from_path__file(self, tmp_path: Path, mocker: MockerFixture) -> None: + mocker.patch("shutil.disk_usage", return_value=(100, 200, 300)) + name = "foo" dto = asyncio.run(MountPointDTO.from_path(name, tmp_path)) - total_bytes, used_bytes, free_bytes = shutil.disk_usage(tmp_path) assert dto.name == name assert dto.path == tmp_path - assert dto.total_bytes == total_bytes - assert dto.used_bytes == used_bytes - assert dto.free_bytes == free_bytes + assert dto.total_bytes == 100 + assert dto.used_bytes == 200 + assert dto.free_bytes == 300 assert re.fullmatch(r"\d+(?:\.\d+)?% used", dto.message), dto.message diff --git a/tests/launcher/test_web.py b/tests/launcher/test_web.py index 8e39e9a910..e1104f87af 100644 --- a/tests/launcher/test_web.py +++ b/tests/launcher/test_web.py @@ -19,6 +19,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient +from antarest.core.application import create_app_ctxt from antarest.core.config import Config, SecurityConfig from antarest.core.jwt import DEFAULT_ADMIN_USER, JWTGroup, JWTUser from antarest.core.requests import RequestParameters @@ -35,10 +36,9 @@ def create_app(service: Mock) -> FastAPI: - app = FastAPI(title=__name__) - + build_ctxt = create_app_ctxt(FastAPI(title=__name__)) build_launcher( - app, + build_ctxt, study_service=Mock(), file_transfer_manager=Mock(), task_service=Mock(), @@ -46,7 +46,7 @@ def create_app(service: Mock) -> FastAPI: config=Config(security=SecurityConfig(disabled=True)), cache=Mock(), ) - return app + return build_ctxt.build() @pytest.mark.unit_test diff --git a/tests/login/test_web.py b/tests/login/test_web.py index c5ae051f69..389dc94cf7 100644 --- a/tests/login/test_web.py +++ b/tests/login/test_web.py @@ -21,6 +21,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient +from antarest.core.application import AppBuildContext, create_app_ctxt from antarest.core.config import Config, SecurityConfig from antarest.core.jwt import JWTGroup, JWTUser from antarest.core.requests import RequestParameters @@ -63,15 +64,16 @@ def get_config(): authjwt_token_location=("headers", "cookies"), ) + app_ctxt = create_app_ctxt(app) build_login( - app, + app_ctxt, service=service, config=Config( resources_path=Path(), security=SecurityConfig(disabled=auth_disabled), ), ) - return app + return app_ctxt.build() class TokenType: diff --git a/tests/matrixstore/test_web.py b/tests/matrixstore/test_web.py index 1e10e2602b..d47fb030bc 100644 --- a/tests/matrixstore/test_web.py +++ b/tests/matrixstore/test_web.py @@ -17,6 +17,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient +from antarest.core.application import create_app_ctxt from antarest.core.config import Config, SecurityConfig from antarest.fastapi_jwt_auth import AuthJWT from antarest.main import JwtSettings @@ -26,7 +27,7 @@ def create_app(service: Mock, auth_disabled=False) -> FastAPI: - app = FastAPI(title=__name__) + build_ctxt = create_app_ctxt(FastAPI(title=__name__)) @AuthJWT.load_config def get_config(): @@ -37,7 +38,7 @@ def get_config(): ) build_matrix_service( - app, + build_ctxt, user_service=Mock(), file_transfer_manager=Mock(), task_service=Mock(), @@ -47,7 +48,7 @@ def get_config(): security=SecurityConfig(disabled=auth_disabled), ), ) - return app + return build_ctxt.build() @pytest.mark.unit_test diff --git a/tests/storage/integration/conftest.py b/tests/storage/integration/conftest.py index 1136f3ca8a..dcf7e5e830 100644 --- a/tests/storage/integration/conftest.py +++ b/tests/storage/integration/conftest.py @@ -109,7 +109,7 @@ def storage_service(tmp_path: Path, project_path: Path, sta_mini_zip_path: Path) ) matrix_service = SimpleMatrixService(matrix_content_repository=matrix_content_repository) storage_service = build_study_service( - application=Mock(), + app_ctxt=Mock(), cache=LocalCache(config=config.cache), file_transfer_manager=Mock(), task_service=task_service_mock, diff --git a/tests/storage/integration/test_STA_mini.py b/tests/storage/integration/test_STA_mini.py index ca7904a075..35aaa4092d 100644 --- a/tests/storage/integration/test_STA_mini.py +++ b/tests/storage/integration/test_STA_mini.py @@ -22,6 +22,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient +from antarest.core.application import create_app_ctxt from antarest.core.jwt import DEFAULT_ADMIN_USER, JWTGroup, JWTUser from antarest.core.model import JSON from antarest.core.requests import RequestParameters @@ -46,19 +47,23 @@ ) -def assert_url_content(storage_service: StudyService, url: str, expected_output: dict) -> None: - app = FastAPI(title=__name__) +def create_test_client(service: StudyService) -> TestClient: + build_ctxt = create_app_ctxt(FastAPI(title=__name__)) build_study_service( - app, + build_ctxt, cache=Mock(), user_service=Mock(), task_service=Mock(), file_transfer_manager=Mock(), - study_service=storage_service, + study_service=service, matrix_service=Mock(spec=MatrixService), - config=storage_service.storage_service.raw_study_service.config, + config=service.storage_service.raw_study_service.config, ) - client = TestClient(app) + return TestClient(build_ctxt.build()) + + +def assert_url_content(storage_service: StudyService, url: str, expected_output: dict) -> None: + client = create_test_client(storage_service) res = client.get(url) assert_study(res.json(), expected_output) @@ -493,18 +498,7 @@ def test_sta_mini_copy(storage_service) -> None: source_study_name = UUID destination_study_name = "copy-STA-mini" - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - user_service=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - matrix_service=Mock(spec=MatrixService), - config=storage_service.storage_service.raw_study_service.config, - ) - client = TestClient(app) + client = create_test_client(storage_service) result = client.post(f"/v1/studies/{source_study_name}/copy?dest={destination_study_name}&use_task=false") assert result.status_code == HTTPStatus.CREATED.value @@ -590,18 +584,7 @@ def test_sta_mini_import(tmp_path: Path, storage_service) -> None: sta_mini_zip_filepath = shutil.make_archive(tmp_path, "zip", path_study) sta_mini_zip_path = Path(sta_mini_zip_filepath) - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - config=storage_service.storage_service.raw_study_service.config, - ) - client = TestClient(app) + client = create_test_client(storage_service) study_data = io.BytesIO(sta_mini_zip_path.read_bytes()) result = client.post("/v1/studies/_import", files={"study": study_data}) @@ -620,18 +603,7 @@ def test_sta_mini_import_output(tmp_path: Path, storage_service) -> None: sta_mini_output_zip_path = Path(sta_mini_output_zip_filepath) - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - config=storage_service.storage_service.raw_study_service.config, - ) - client = TestClient(app) + client = create_test_client(storage_service) study_output_data = io.BytesIO(sta_mini_output_zip_path.read_bytes()) result = client.post( diff --git a/tests/storage/integration/test_exporter.py b/tests/storage/integration/test_exporter.py index 2da0686302..46e077a9ca 100644 --- a/tests/storage/integration/test_exporter.py +++ b/tests/storage/integration/test_exporter.py @@ -21,6 +21,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient +from antarest.core.application import create_app_ctxt from antarest.core.config import Config, SecurityConfig, StorageConfig, WorkspaceConfig from antarest.core.filetransfer.model import FileDownloadTaskDTO from antarest.core.jwt import DEFAULT_ADMIN_USER @@ -54,10 +55,10 @@ def assert_url_content(url: str, tmp_dir: Path, sta_mini_zip_path: Path) -> byte repo = Mock() repo.get.return_value = md - app = FastAPI(title=__name__) + build_ctxt = create_app_ctxt(FastAPI(title=__name__)) ftm = SimpleFileTransferManager(Config(storage=StorageConfig(tmp_dir=tmp_dir))) build_study_service( - app, + build_ctxt, cache=Mock(), user_service=Mock(), task_service=SimpleSyncTaskService(), @@ -69,7 +70,7 @@ def assert_url_content(url: str, tmp_dir: Path, sta_mini_zip_path: Path) -> byte ) # Simulate the download of data using a streamed request - client = TestClient(app) + client = TestClient(build_ctxt.build()) if client.stream is False: # `TestClient` is based on `Requests` (old way before AntaREST-v2.15) # noinspection PyArgumentList diff --git a/tests/storage/web/test_studies_bp.py b/tests/storage/web/test_studies_bp.py index c9a3c18be2..33cedcc8df 100644 --- a/tests/storage/web/test_studies_bp.py +++ b/tests/storage/web/test_studies_bp.py @@ -24,9 +24,11 @@ from markupsafe import Markup from starlette.testclient import TestClient +from antarest.core.application import create_app_ctxt from antarest.core.config import Config, SecurityConfig, StorageConfig, WorkspaceConfig from antarest.core.exceptions import UrlNotMatchJsonDataError from antarest.core.filetransfer.model import FileDownloadDTO, FileDownloadTaskDTO +from antarest.core.filetransfer.service import FileTransferManager from antarest.core.jwt import JWTGroup, JWTUser from antarest.core.requests import RequestParameters from antarest.core.roles import RoleType @@ -48,6 +50,7 @@ TimeSerie, TimeSeriesData, ) +from antarest.study.service import StudyService from tests.storage.conftest import SimpleFileTransferManager from tests.storage.integration.conftest import UUID @@ -66,23 +69,29 @@ ) -@pytest.mark.unit_test -def test_server() -> None: - mock_service = Mock() - mock_service.get.return_value = {} - - app = FastAPI(title=__name__) +def create_test_client( + service: StudyService, file_transfer_manager: FileTransferManager = Mock(), raise_server_exceptions: bool = True +) -> TestClient: + app_ctxt = create_app_ctxt(FastAPI(title=__name__)) build_study_service( - app, + app_ctxt, cache=Mock(), task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_service, + file_transfer_manager=file_transfer_manager, + study_service=service, config=CONFIG, user_service=Mock(), matrix_service=Mock(spec=MatrixService), ) - client = TestClient(app) + return TestClient(app_ctxt.build(), raise_server_exceptions=raise_server_exceptions) + + +@pytest.mark.unit_test +def test_server() -> None: + mock_service = Mock() + mock_service.get.return_value = {} + + client = create_test_client(mock_service) client.get("/v1/studies/study1/raw?path=settings/general/params") mock_service.get.assert_called_once_with( @@ -95,18 +104,7 @@ def test_404() -> None: mock_storage_service = Mock() mock_storage_service.get.side_effect = UrlNotMatchJsonDataError("Test") - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + client = create_test_client(mock_storage_service, raise_server_exceptions=False) result = client.get("/v1/studies/study1/raw?path=settings/general/params") assert result.status_code == HTTPStatus.NOT_FOUND @@ -119,18 +117,7 @@ def test_server_with_parameters() -> None: mock_storage_service = Mock() mock_storage_service.get.return_value = {} - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(mock_storage_service) result = client.get("/v1/studies/study1/raw?depth=4") parameters = RequestParameters(user=ADMIN) @@ -158,18 +145,7 @@ def test_create_study(tmp_path: str, project_path) -> None: storage_service = Mock() storage_service.create_study.return_value = "my-uuid" - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(storage_service) result_right = client.post("/v1/studies?name=study2") @@ -193,18 +169,7 @@ def test_import_study_zipped(tmp_path: Path, project_path) -> None: study_uuid = str(uuid.uuid4()) mock_storage_service.import_study.return_value = study_uuid - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(mock_storage_service) result = client.post("/v1/studies") @@ -223,18 +188,7 @@ def test_copy_study(tmp_path: Path) -> None: storage_service = Mock() storage_service.copy_study.return_value = "/studies/study-copied" - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(storage_service) result = client.post(f"/v1/studies/{UUID}/copy?dest=study-copied") @@ -285,18 +239,7 @@ def test_list_studies(tmp_path: str) -> None: storage_service = Mock() storage_service.get_studies_information.return_value = studies - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(storage_service) result = client.get("/v1/studies") assert {k: StudyMetadataDTO.model_validate(v) for k, v in result.json().items()} == studies @@ -320,18 +263,7 @@ def test_study_metadata(tmp_path: str) -> None: storage_service = Mock() storage_service.get_study_information.return_value = study - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(storage_service) result = client.get("/v1/studies/1") assert StudyMetadataDTO.model_validate(result.json()) == study @@ -352,20 +284,8 @@ def test_export_files(tmp_path: Path) -> None: ) mock_storage_service.export_study.return_value = expected - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - # Simulate the download of data using a streamed request - client = TestClient(app) + client = create_test_client(mock_storage_service) if client.stream is False: # `TestClient` is based on `Requests` (old way before AntaREST-v2.15) # noinspection PyArgumentList @@ -402,18 +322,7 @@ def test_export_params(tmp_path: Path) -> None: ) mock_storage_service.export_study.return_value = expected - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(mock_storage_service) client.get(f"/v1/studies/{UUID}/export?no_output=true") client.get(f"/v1/studies/{UUID}/export?no_output=false") mock_storage_service.export_study.assert_has_calls( @@ -428,18 +337,7 @@ def test_export_params(tmp_path: Path) -> None: def test_delete_study() -> None: mock_storage_service = Mock() - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(mock_storage_service) study_uuid = "8319b5f8-2a35-4984-9ace-2ab072bd6eef" client.delete(f"/v1/studies/{study_uuid}") @@ -452,18 +350,7 @@ def test_edit_study() -> None: mock_storage_service = Mock() mock_storage_service.edit_study.return_value = {} - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_storage_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app) + client = create_test_client(mock_storage_service) client.post("/v1/studies/my-uuid/raw?path=url/to/change", json={"Hello": "World"}) mock_storage_service.edit_study.assert_called_once_with("my-uuid", "url/to/change", {"Hello": "World"}, PARAMS) @@ -497,18 +384,7 @@ def test_validate() -> None: mock_service = Mock() mock_service.check_errors.return_value = ["Hello"] - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + client = create_test_client(mock_service, raise_server_exceptions=False) res = client.get("/v1/studies/my-uuid/raw/validate") assert res.json() == ["Hello"] @@ -551,19 +427,8 @@ def test_output_download(tmp_path: Path) -> None: synthesis=False, includeClusters=True, ) - - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=SimpleFileTransferManager(Config(storage=StorageConfig(tmp_dir=tmp_path))), - study_service=mock_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + ftm = SimpleFileTransferManager(Config(storage=StorageConfig(tmp_dir=tmp_path))) + client = create_test_client(mock_service, ftm, raise_server_exceptions=False) res = client.post( f"/v1/studies/{UUID}/outputs/my-output-id/download", json=study_download.model_dump(), @@ -588,18 +453,8 @@ def test_output_whole_download(tmp_path: Path) -> None: ) mock_service.export_output.return_value = expected - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=SimpleFileTransferManager(Config(storage=StorageConfig(tmp_dir=tmp_path))), - study_service=mock_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + ftm = SimpleFileTransferManager(Config(storage=StorageConfig(tmp_dir=tmp_path))) + client = create_test_client(mock_service, ftm, raise_server_exceptions=False) res = client.get( f"/v1/studies/{UUID}/outputs/{output_id}/export", ) @@ -612,18 +467,7 @@ def test_sim_reference() -> None: study_id = str(uuid.uuid4()) output_id = "my-output-id" - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + client = create_test_client(mock_service, raise_server_exceptions=False) res = client.put(f"/v1/studies/{study_id}/outputs/{output_id}/reference") mock_service.set_sim_reference.assert_called_once_with(study_id, output_id, True, PARAMS) assert res.status_code == HTTPStatus.OK @@ -656,18 +500,8 @@ def test_sim_result() -> None: ) ] mock_service.get_study_sim_result.return_value = result_data - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=mock_service, - config=CONFIG, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - ) - client = TestClient(app, raise_server_exceptions=False) + + client = create_test_client(mock_service, raise_server_exceptions=False) res = client.get(f"/v1/studies/{study_id}/outputs") actual_object = [StudySimResultDTO.parse_obj(res.json()[0])] assert actual_object == result_data @@ -676,19 +510,7 @@ def test_sim_result() -> None: @pytest.mark.unit_test def test_study_permission_management(tmp_path: Path) -> None: storage_service = Mock() - - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=storage_service, - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - config=CONFIG, - ) - client = TestClient(app, raise_server_exceptions=False) + client = create_test_client(storage_service, raise_server_exceptions=False) result = client.put(f"/v1/studies/{UUID}/owner/2") storage_service.change_owner.assert_called_with( @@ -728,18 +550,7 @@ def test_study_permission_management(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_study_versions(tmp_path: Path) -> None: - app = FastAPI(title=__name__) - build_study_service( - app, - cache=Mock(), - task_service=Mock(), - file_transfer_manager=Mock(), - study_service=Mock(), - user_service=Mock(), - matrix_service=Mock(spec=MatrixService), - config=CONFIG, - ) - client = TestClient(app, raise_server_exceptions=False) + client = create_test_client(Mock(), raise_server_exceptions=False) result = client.get("/v1/studies/_versions") assert result.json() == list(STUDY_REFERENCE_TEMPLATES.keys()) diff --git a/tests/test_front.py b/tests/test_front.py new file mode 100644 index 0000000000..5046a868cf --- /dev/null +++ b/tests/test_front.py @@ -0,0 +1,111 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +from pathlib import Path + +import pytest +from fastapi import FastAPI +from starlette.testclient import TestClient + +from antarest.front import RedirectMiddleware, add_front_app + + +@pytest.fixture +def base_back_app() -> FastAPI: + """ + A simple app which has only one backend endpoint + """ + app = FastAPI(title=__name__) + + @app.get(path="/api/a-backend-endpoint") + def get_from_api() -> str: + return "back" + + return app + + +@pytest.fixture +def resources_dir(tmp_path: Path) -> Path: + resource_dir = tmp_path / "resources" + resource_dir.mkdir() + webapp_dir = resource_dir / "webapp" + webapp_dir.mkdir() + with open(webapp_dir / "index.html", mode="w") as f: + f.write("index") + with open(webapp_dir / "front.css", mode="w") as f: + f.write("css") + return resource_dir + + +@pytest.fixture +def app_with_home(base_back_app) -> FastAPI: + """ + A simple app which has only a home endpoint and one backend endpoint + """ + + @base_back_app.get(path="/") + def home() -> str: + return "home" + + return base_back_app + + +@pytest.fixture +def redirect_app(app_with_home: FastAPI) -> FastAPI: + """ + Same as app with redirect middleware + """ + route_paths = [r.path for r in app_with_home.routes] # type: ignore + app_with_home.add_middleware(RedirectMiddleware, route_paths=route_paths) + return app_with_home + + +def test_redirect_middleware_does_not_modify_home(redirect_app: FastAPI) -> None: + client = TestClient(redirect_app) + response = client.get("/") + assert response.status_code == 200 + assert response.json() == "home" + + +def test_redirect_middleware_redirects_unknown_routes_to_home(redirect_app: FastAPI) -> None: + client = TestClient(redirect_app) + response = client.get("/a-front-route") + assert response.status_code == 200 + assert response.json() == "home" + + +def test_redirect_middleware_does_not_redirect_backend_routes(redirect_app: FastAPI) -> None: + client = TestClient(redirect_app) + response = client.get("/api/a-backend-endpoint") + assert response.status_code == 200 + assert response.json() == "back" + + +def test_frontend_paths(base_back_app, resources_dir: Path) -> None: + add_front_app(base_back_app, resources_dir, "/api") + client = TestClient(base_back_app) + + config_response = client.get("/config.json") + assert config_response.status_code == 200 + assert config_response.json() == {"restEndpoint": "/api", "wsEndpoint": "/api/ws"} + + index_response = client.get("/index.html") + assert index_response.status_code == 200 + assert index_response.text == "index" + + front_route_response = client.get("/any-route") + assert front_route_response.status_code == 200 + assert front_route_response.text == "index" + + front_static_file_response = client.get("/static/front.css") + assert front_static_file_response.status_code == 200 + assert front_static_file_response.text == "css" diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index f20985f808..b78ba393e5 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -13,6 +13,7 @@ import importlib import itertools import pkgutil +from typing import List, Set from unittest.mock import Mock import pytest @@ -25,18 +26,385 @@ from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.model import CommandDTO +COMMANDS: List[CommandDTO] = [ + CommandDTO( + action=CommandName.CREATE_AREA.value, + args={"area_name": "area_name"}, + ), + CommandDTO( + action=CommandName.CREATE_AREA.value, + args=[ + {"area_name": "area_name"}, + {"area_name": "area2"}, + ], + ), + CommandDTO( + action=CommandName.REMOVE_AREA.value, + args={"id": "id"}, + ), + CommandDTO( + action=CommandName.REMOVE_AREA.value, + args=[{"id": "id"}], + ), + CommandDTO( + action=CommandName.CREATE_DISTRICT.value, + args={ + "name": "id", + "filter_items": ["a"], + "output": True, + "comments": "", + }, + ), + CommandDTO( + action=CommandName.CREATE_DISTRICT.value, + args=[ + { + "name": "id", + "base_filter": "add-all", + "output": True, + "comments": "", + } + ], + ), + CommandDTO( + action=CommandName.REMOVE_DISTRICT.value, + args={"id": "id"}, + ), + CommandDTO( + action=CommandName.REMOVE_DISTRICT.value, + args=[{"id": "id"}], + ), + CommandDTO( + action=CommandName.CREATE_LINK.value, + args={ + "area1": "area1", + "area2": "area2", + "parameters": {}, + "series": "series", + }, + ), + CommandDTO( + action=CommandName.CREATE_LINK.value, + args=[ + { + "area1": "area1", + "area2": "area2", + "parameters": {}, + "series": "series", + } + ], + ), + CommandDTO( + action=CommandName.REMOVE_LINK.value, + args={ + "area1": "area1", + "area2": "area2", + }, + ), + CommandDTO( + action=CommandName.REMOVE_LINK.value, + args=[ + { + "area1": "area1", + "area2": "area2", + } + ], + ), + CommandDTO( + action=CommandName.CREATE_BINDING_CONSTRAINT.value, + args={"name": "name"}, + ), + CommandDTO( + action=CommandName.CREATE_BINDING_CONSTRAINT.value, + args=[ + { + "name": "name", + "enabled": True, + "time_step": "hourly", + "operator": "equal", + "values": "values", + "group": "group_1", + }, + ], + ), + CommandDTO( + action=CommandName.UPDATE_BINDING_CONSTRAINT.value, + args={ + "id": "id", + "enabled": True, + "time_step": "hourly", + "operator": "equal", + "values": "values", + }, + ), + CommandDTO( + action=CommandName.UPDATE_BINDING_CONSTRAINT.value, + args=[ + { + "id": "id", + "enabled": True, + "time_step": "hourly", + "operator": "equal", + } + ], + ), + CommandDTO( + action=CommandName.REMOVE_BINDING_CONSTRAINT.value, + args={"id": "id"}, + ), + CommandDTO( + action=CommandName.REMOVE_BINDING_CONSTRAINT.value, + args=[{"id": "id"}], + ), + CommandDTO( + action=CommandName.CREATE_THERMAL_CLUSTER.value, + args={ + "area_id": "area_name", + "cluster_name": "cluster_name", + "parameters": { + "group": "group", + "unitcount": "unitcount", + "nominalcapacity": "nominalcapacity", + "marginal-cost": "marginal-cost", + "market-bid-cost": "market-bid-cost", + }, + "prepro": "prepro", + "modulation": "modulation", + }, + ), + CommandDTO( + action=CommandName.CREATE_THERMAL_CLUSTER.value, + args=[ + { + "area_id": "area_name", + "cluster_name": "cluster_name", + "parameters": { + "group": "group", + "unitcount": "unitcount", + "nominalcapacity": "nominalcapacity", + "marginal-cost": "marginal-cost", + "market-bid-cost": "market-bid-cost", + }, + "prepro": "prepro", + "modulation": "modulation", + } + ], + ), + CommandDTO( + action=CommandName.REMOVE_THERMAL_CLUSTER.value, + args={"area_id": "area_name", "cluster_id": "cluster_name"}, + ), + CommandDTO( + action=CommandName.REMOVE_THERMAL_CLUSTER.value, + args=[{"area_id": "area_name", "cluster_id": "cluster_name"}], + ), + CommandDTO( + action=CommandName.CREATE_RENEWABLES_CLUSTER.value, + args={ + "area_id": "area_name", + "cluster_name": "cluster_name", + "parameters": { + "name": "name", + "ts-interpretation": "power-generation", + }, + }, + ), + CommandDTO( + action=CommandName.CREATE_RENEWABLES_CLUSTER.value, + args=[ + { + "area_id": "area_name", + "cluster_name": "cluster_name", + "parameters": { + "name": "name", + "ts-interpretation": "power-generation", + }, + } + ], + ), + CommandDTO( + action=CommandName.REMOVE_RENEWABLES_CLUSTER.value, + args={"area_id": "area_name", "cluster_id": "cluster_name"}, + ), + CommandDTO( + action=CommandName.REMOVE_RENEWABLES_CLUSTER.value, + args=[{"area_id": "area_name", "cluster_id": "cluster_name"}], + ), + CommandDTO( + action=CommandName.REPLACE_MATRIX.value, + args={"target": "target_element", "matrix": "matrix"}, + ), + CommandDTO( + action=CommandName.REPLACE_MATRIX.value, + args=[{"target": "target_element", "matrix": "matrix"}], + ), + CommandDTO( + action=CommandName.UPDATE_CONFIG.value, + args={"target": "target", "data": {}}, + ), + CommandDTO( + action=CommandName.UPDATE_CONFIG.value, + args=[{"target": "target", "data": {}}], + ), + CommandDTO( + action=CommandName.UPDATE_COMMENTS.value, + args={"comments": "comments"}, + ), + CommandDTO( + action=CommandName.UPDATE_COMMENTS.value, + args=[{"comments": "comments"}], + ), + CommandDTO( + action=CommandName.UPDATE_FILE.value, + args={ + "target": "settings/resources/study", + "b64Data": "", + }, + ), + CommandDTO( + action=CommandName.UPDATE_DISTRICT.value, + args={"id": "id", "filter_items": ["a"]}, + ), + CommandDTO( + action=CommandName.UPDATE_DISTRICT.value, + args=[{"id": "id", "base_filter": "add-all"}], + ), + CommandDTO( + action=CommandName.UPDATE_PLAYLIST.value, + args=[{"active": True, "items": [1, 3], "reverse": False}], + ), + CommandDTO( + action=CommandName.UPDATE_PLAYLIST.value, + args={ + "active": True, + "items": [1, 3], + "weights": {1: 5.0}, + "reverse": False, + }, + ), + CommandDTO( + action=CommandName.UPDATE_SCENARIO_BUILDER.value, + args={ + "data": { + "ruleset test": { + "l": {"area1": {"0": 1}}, + "ntc": {"area1 / area2": {"1": 23}}, + "t": {"area1": {"thermal": {"1": 2}}}, + }, + } + }, + ), + CommandDTO( + action=CommandName.CREATE_ST_STORAGE.value, + args={ + "area_id": "area 1", + "parameters": { + "name": "Storage 1", + "group": "Battery", + "injectionnominalcapacity": 0, + "withdrawalnominalcapacity": 0, + "reservoircapacity": 0, + "efficiency": 1, + "initiallevel": 0, + "initialleveloptim": False, + }, + "pmax_injection": "matrix://59ea6c83-6348-466d-9530-c35c51ca4c37", + "pmax_withdrawal": "matrix://5f988548-dadc-4bbb-8ce8-87a544dbf756", + "lower_rule_curve": "matrix://8ce4fcea-cc97-4d2c-b641-a27a53454612", + "upper_rule_curve": "matrix://8ce614c8-c687-41af-8b24-df8a49cc52af", + "inflows": "matrix://df9b25e1-e3f7-4a57-8182-0ff9791439e5", + }, + ), + CommandDTO( + action=CommandName.CREATE_ST_STORAGE.value, + args=[ + { + "area_id": "area 1", + "parameters": { + "efficiency": 1, + "group": "Battery", + "initiallevel": 0, + "initialleveloptim": False, + "injectionnominalcapacity": 0, + "name": "Storage 1", + "reservoircapacity": 0, + "withdrawalnominalcapacity": 0, + }, + "pmax_injection": "matrix://59ea6c83-6348-466d-9530-c35c51ca4c37", + "pmax_withdrawal": "matrix://5f988548-dadc-4bbb-8ce8-87a544dbf756", + "lower_rule_curve": "matrix://8ce4fcea-cc97-4d2c-b641-a27a53454612", + "upper_rule_curve": "matrix://8ce614c8-c687-41af-8b24-df8a49cc52af", + "inflows": "matrix://df9b25e1-e3f7-4a57-8182-0ff9791439e5", + }, + { + "area_id": "area 1", + "parameters": { + "efficiency": 0.94, + "group": "Battery", + "initiallevel": 0, + "initialleveloptim": False, + "injectionnominalcapacity": 0, + "name": "Storage 2", + "reservoircapacity": 0, + "withdrawalnominalcapacity": 0, + }, + "pmax_injection": "matrix://3f5b3746-3995-49b7-a6da-622633472e05", + "pmax_withdrawal": "matrix://4b64a31f-927b-4887-b4cd-adcddd39bdcd", + "lower_rule_curve": "matrix://16c7c3ae-9824-4ef2-aa68-51145884b025", + "upper_rule_curve": "matrix://9a6104e9-990a-415f-a6e2-57507e13b58c", + "inflows": "matrix://e8923768-9bdd-40c2-a6ea-2da2523be727", + }, + ], + ), + CommandDTO( + action=CommandName.REMOVE_ST_STORAGE.value, + args={ + "area_id": "area 1", + "storage_id": "storage 1", + }, + ), + CommandDTO( + action=CommandName.REMOVE_ST_STORAGE.value, + args=[ + { + "area_id": "area 1", + "storage_id": "storage 1", + }, + { + "area_id": "area 1", + "storage_id": "storage 2", + }, + ], + ), + CommandDTO( + action=CommandName.GENERATE_THERMAL_CLUSTER_TIMESERIES.value, + args=[{}], + ), +] + + +@pytest.fixture +def command_factory() -> CommandFactory: + def get_matrix_id(matrix: str) -> str: + # str.removeprefix() is not available in Python 3.8 + prefix = "matrix://" + if matrix.startswith(prefix): + return matrix[len(prefix) :] + return matrix + + return CommandFactory( + generator_matrix_constants=Mock(spec=GeneratorMatrixConstants), + matrix_service=Mock(spec=MatrixService, get_matrix_id=get_matrix_id), + patch_service=Mock(spec=PatchService), + ) + class TestCommandFactory: - # noinspection SpellCheckingInspection - def setup_class(self): + def _get_command_classes(self) -> Set[str]: """ - Set up the test class. - Imports all modules from the `antarest.study.storage.variantstudy.model.command` package and creates a set of command class names derived from the `ICommand` abstract class. The objective is to ensure that the unit test covers all commands in this package. - - This method is executed once before any tests in the test class are run. """ for module_loader, name, ispkg in pkgutil.iter_modules(["antarest/study/storage/variantstudy/model/command"]): importlib.import_module( @@ -44,383 +412,21 @@ def setup_class(self): package="antarest.study.storage.variantstudy.model.command", ) abstract_commands = {"AbstractBindingConstraintCommand"} - self.command_class_set = { - cmd.__name__ for cmd in ICommand.__subclasses__() if cmd.__name__ not in abstract_commands - } + return {cmd.__name__ for cmd in ICommand.__subclasses__() if cmd.__name__ not in abstract_commands} + + def test_all_commands_are_tested(self, command_factory: CommandFactory): + commands = sum([command_factory.to_command(command_dto=cmd) for cmd in COMMANDS], []) + tested_classes = {c.__class__.__name__ for c in commands} + + assert self._get_command_classes().issubset(tested_classes) # noinspection SpellCheckingInspection @pytest.mark.parametrize( "command_dto", - [ - CommandDTO( - action=CommandName.CREATE_AREA.value, - args={"area_name": "area_name"}, - ), - CommandDTO( - action=CommandName.CREATE_AREA.value, - args=[ - {"area_name": "area_name"}, - {"area_name": "area2"}, - ], - ), - CommandDTO( - action=CommandName.REMOVE_AREA.value, - args={"id": "id"}, - ), - CommandDTO( - action=CommandName.REMOVE_AREA.value, - args=[{"id": "id"}], - ), - CommandDTO( - action=CommandName.CREATE_DISTRICT.value, - args={ - "name": "id", - "filter_items": ["a"], - "output": True, - "comments": "", - }, - ), - CommandDTO( - action=CommandName.CREATE_DISTRICT.value, - args=[ - { - "name": "id", - "base_filter": "add-all", - "output": True, - "comments": "", - } - ], - ), - CommandDTO( - action=CommandName.REMOVE_DISTRICT.value, - args={"id": "id"}, - ), - CommandDTO( - action=CommandName.REMOVE_DISTRICT.value, - args=[{"id": "id"}], - ), - CommandDTO( - action=CommandName.CREATE_LINK.value, - args={ - "area1": "area1", - "area2": "area2", - "parameters": {}, - "series": "series", - }, - ), - CommandDTO( - action=CommandName.CREATE_LINK.value, - args=[ - { - "area1": "area1", - "area2": "area2", - "parameters": {}, - "series": "series", - } - ], - ), - CommandDTO( - action=CommandName.REMOVE_LINK.value, - args={ - "area1": "area1", - "area2": "area2", - }, - ), - CommandDTO( - action=CommandName.REMOVE_LINK.value, - args=[ - { - "area1": "area1", - "area2": "area2", - } - ], - ), - CommandDTO( - action=CommandName.CREATE_BINDING_CONSTRAINT.value, - args={"name": "name"}, - ), - CommandDTO( - action=CommandName.CREATE_BINDING_CONSTRAINT.value, - args=[ - { - "name": "name", - "enabled": True, - "time_step": "hourly", - "operator": "equal", - "values": "values", - "group": "group_1", - }, - ], - ), - CommandDTO( - action=CommandName.UPDATE_BINDING_CONSTRAINT.value, - args={ - "id": "id", - "enabled": True, - "time_step": "hourly", - "operator": "equal", - "values": "values", - }, - ), - CommandDTO( - action=CommandName.UPDATE_BINDING_CONSTRAINT.value, - args=[ - { - "id": "id", - "enabled": True, - "time_step": "hourly", - "operator": "equal", - } - ], - ), - CommandDTO( - action=CommandName.REMOVE_BINDING_CONSTRAINT.value, - args={"id": "id"}, - ), - CommandDTO( - action=CommandName.REMOVE_BINDING_CONSTRAINT.value, - args=[{"id": "id"}], - ), - CommandDTO( - action=CommandName.CREATE_THERMAL_CLUSTER.value, - args={ - "area_id": "area_name", - "cluster_name": "cluster_name", - "parameters": { - "group": "group", - "unitcount": "unitcount", - "nominalcapacity": "nominalcapacity", - "marginal-cost": "marginal-cost", - "market-bid-cost": "market-bid-cost", - }, - "prepro": "prepro", - "modulation": "modulation", - }, - ), - CommandDTO( - action=CommandName.CREATE_THERMAL_CLUSTER.value, - args=[ - { - "area_id": "area_name", - "cluster_name": "cluster_name", - "parameters": { - "group": "group", - "unitcount": "unitcount", - "nominalcapacity": "nominalcapacity", - "marginal-cost": "marginal-cost", - "market-bid-cost": "market-bid-cost", - }, - "prepro": "prepro", - "modulation": "modulation", - } - ], - ), - CommandDTO( - action=CommandName.REMOVE_THERMAL_CLUSTER.value, - args={"area_id": "area_name", "cluster_id": "cluster_name"}, - ), - CommandDTO( - action=CommandName.REMOVE_THERMAL_CLUSTER.value, - args=[{"area_id": "area_name", "cluster_id": "cluster_name"}], - ), - CommandDTO( - action=CommandName.CREATE_RENEWABLES_CLUSTER.value, - args={ - "area_id": "area_name", - "cluster_name": "cluster_name", - "parameters": { - "name": "name", - "ts-interpretation": "power-generation", - }, - }, - ), - CommandDTO( - action=CommandName.CREATE_RENEWABLES_CLUSTER.value, - args=[ - { - "area_id": "area_name", - "cluster_name": "cluster_name", - "parameters": { - "name": "name", - "ts-interpretation": "power-generation", - }, - } - ], - ), - CommandDTO( - action=CommandName.REMOVE_RENEWABLES_CLUSTER.value, - args={"area_id": "area_name", "cluster_id": "cluster_name"}, - ), - CommandDTO( - action=CommandName.REMOVE_RENEWABLES_CLUSTER.value, - args=[{"area_id": "area_name", "cluster_id": "cluster_name"}], - ), - CommandDTO( - action=CommandName.REPLACE_MATRIX.value, - args={"target": "target_element", "matrix": "matrix"}, - ), - CommandDTO( - action=CommandName.REPLACE_MATRIX.value, - args=[{"target": "target_element", "matrix": "matrix"}], - ), - CommandDTO( - action=CommandName.UPDATE_CONFIG.value, - args={"target": "target", "data": {}}, - ), - CommandDTO( - action=CommandName.UPDATE_CONFIG.value, - args=[{"target": "target", "data": {}}], - ), - CommandDTO( - action=CommandName.UPDATE_COMMENTS.value, - args={"comments": "comments"}, - ), - CommandDTO( - action=CommandName.UPDATE_COMMENTS.value, - args=[{"comments": "comments"}], - ), - CommandDTO( - action=CommandName.UPDATE_FILE.value, - args={ - "target": "settings/resources/study", - "b64Data": "", - }, - ), - CommandDTO( - action=CommandName.UPDATE_DISTRICT.value, - args={"id": "id", "filter_items": ["a"]}, - ), - CommandDTO( - action=CommandName.UPDATE_DISTRICT.value, - args=[{"id": "id", "base_filter": "add-all"}], - ), - CommandDTO( - action=CommandName.UPDATE_PLAYLIST.value, - args=[{"active": True, "items": [1, 3], "reverse": False}], - ), - CommandDTO( - action=CommandName.UPDATE_PLAYLIST.value, - args={ - "active": True, - "items": [1, 3], - "weights": {1: 5.0}, - "reverse": False, - }, - ), - CommandDTO( - action=CommandName.UPDATE_SCENARIO_BUILDER.value, - args={ - "data": { - "ruleset test": { - "l": {"area1": {"0": 1}}, - "ntc": {"area1 / area2": {"1": 23}}, - "t": {"area1": {"thermal": {"1": 2}}}, - }, - } - }, - ), - CommandDTO( - action=CommandName.CREATE_ST_STORAGE.value, - args={ - "area_id": "area 1", - "parameters": { - "name": "Storage 1", - "group": "Battery", - "injectionnominalcapacity": 0, - "withdrawalnominalcapacity": 0, - "reservoircapacity": 0, - "efficiency": 1, - "initiallevel": 0, - "initialleveloptim": False, - }, - "pmax_injection": "matrix://59ea6c83-6348-466d-9530-c35c51ca4c37", - "pmax_withdrawal": "matrix://5f988548-dadc-4bbb-8ce8-87a544dbf756", - "lower_rule_curve": "matrix://8ce4fcea-cc97-4d2c-b641-a27a53454612", - "upper_rule_curve": "matrix://8ce614c8-c687-41af-8b24-df8a49cc52af", - "inflows": "matrix://df9b25e1-e3f7-4a57-8182-0ff9791439e5", - }, - ), - CommandDTO( - action=CommandName.CREATE_ST_STORAGE.value, - args=[ - { - "area_id": "area 1", - "parameters": { - "efficiency": 1, - "group": "Battery", - "initiallevel": 0, - "initialleveloptim": False, - "injectionnominalcapacity": 0, - "name": "Storage 1", - "reservoircapacity": 0, - "withdrawalnominalcapacity": 0, - }, - "pmax_injection": "matrix://59ea6c83-6348-466d-9530-c35c51ca4c37", - "pmax_withdrawal": "matrix://5f988548-dadc-4bbb-8ce8-87a544dbf756", - "lower_rule_curve": "matrix://8ce4fcea-cc97-4d2c-b641-a27a53454612", - "upper_rule_curve": "matrix://8ce614c8-c687-41af-8b24-df8a49cc52af", - "inflows": "matrix://df9b25e1-e3f7-4a57-8182-0ff9791439e5", - }, - { - "area_id": "area 1", - "parameters": { - "efficiency": 0.94, - "group": "Battery", - "initiallevel": 0, - "initialleveloptim": False, - "injectionnominalcapacity": 0, - "name": "Storage 2", - "reservoircapacity": 0, - "withdrawalnominalcapacity": 0, - }, - "pmax_injection": "matrix://3f5b3746-3995-49b7-a6da-622633472e05", - "pmax_withdrawal": "matrix://4b64a31f-927b-4887-b4cd-adcddd39bdcd", - "lower_rule_curve": "matrix://16c7c3ae-9824-4ef2-aa68-51145884b025", - "upper_rule_curve": "matrix://9a6104e9-990a-415f-a6e2-57507e13b58c", - "inflows": "matrix://e8923768-9bdd-40c2-a6ea-2da2523be727", - }, - ], - ), - CommandDTO( - action=CommandName.REMOVE_ST_STORAGE.value, - args={ - "area_id": "area 1", - "storage_id": "storage 1", - }, - ), - CommandDTO( - action=CommandName.REMOVE_ST_STORAGE.value, - args=[ - { - "area_id": "area 1", - "storage_id": "storage 1", - }, - { - "area_id": "area 1", - "storage_id": "storage 2", - }, - ], - ), - CommandDTO( - action=CommandName.GENERATE_THERMAL_CLUSTER_TIMESERIES.value, - args=[{}], - ), - ], + COMMANDS, ) @pytest.mark.unit_test - def test_command_factory(self, command_dto: CommandDTO): - def get_matrix_id(matrix: str) -> str: - # str.removeprefix() is not available in Python 3.8 - prefix = "matrix://" - if matrix.startswith(prefix): - return matrix[len(prefix) :] - return matrix - - command_factory = CommandFactory( - generator_matrix_constants=Mock(spec=GeneratorMatrixConstants), - matrix_service=Mock(spec=MatrixService, get_matrix_id=get_matrix_id), - patch_service=Mock(spec=PatchService), - ) + def test_command_factory(self, command_dto: CommandDTO, command_factory: CommandFactory): commands = command_factory.to_command(command_dto=command_dto) if isinstance(command_dto.args, dict): @@ -440,12 +446,6 @@ def get_matrix_id(matrix: str) -> str: assert actual_args == expected_args assert actual_version == expected_version - self.command_class_set.discard(type(commands[0]).__name__) - - def teardown_class(self): - # Check that all command classes have been tested - assert not self.command_class_set - @pytest.mark.unit_test def test_unknown_command(): diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx index c48e67d508..d035a94cce 100644 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ b/webapp/src/components/common/MatrixInput/index.tsx @@ -183,7 +183,7 @@ function MatrixInput({ isPercentDisplayEnabled={enablePercentDisplay} /> ) : ( - !isLoading && + !isLoading && )} {openImportDialog && ( diff --git a/webapp/src/components/common/page/SimpleContent.tsx b/webapp/src/components/common/page/SimpleContent.tsx index 6be0cd51b0..03c8525eec 100644 --- a/webapp/src/components/common/page/SimpleContent.tsx +++ b/webapp/src/components/common/page/SimpleContent.tsx @@ -16,6 +16,7 @@ function EmptyView(props: EmptyViewProps) {