diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index e81d84a2c4..9c8e5df254 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -87,7 +87,7 @@ jobs: docker run --rm -v /tmp:/tmp:ro tufin/oasdiff changelog \ --format markup \ /tmp/openapi.json /tmp/openapi2.json \ - | sed 's/\/anyOf\[subschema #[0-9]\+\(: [a-zA-Z]\+\)\?\]//g' + | sed 's/#\([0-9]\+\)/\1/g' echo 'EOF' } >> "$GITHUB_OUTPUT" - name: Find existing comment on PR diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77f6dba1a8..c24d8a7d7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,9 +83,14 @@ repos: - id: pylint name: pylint entry: pylint - args: [--rcfile=./backend/pyproject.toml] + args: [ + '-rn', # Only display messages + '-sn', # Don't display the score + '--rcfile=./backend/pyproject.toml', + ] language: system types: [python] + require_serial: true files: '^backend' exclude: '^backend/capellacollab/alembic/' - repo: local @@ -101,6 +106,8 @@ repos: - 'prettier-plugin-tailwindcss@^0.6.8' - '@trivago/prettier-plugin-sort-imports@^4.3.0' - 'tailwindcss@^3.4.12' + - 'prettier-plugin-classnames@^0.7.4' + - 'prettier-plugin-merge@^0.7.1' - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.5.5 hooks: diff --git a/backend/capellacollab/alembic/versions/014438261702_add_provisioning_feature.py b/backend/capellacollab/alembic/versions/014438261702_add_provisioning_feature.py new file mode 100644 index 0000000000..4b0a79a47a --- /dev/null +++ b/backend/capellacollab/alembic/versions/014438261702_add_provisioning_feature.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add provisioning feature + +Revision ID: 014438261702 +Revises: 320c5b39c509 +Create Date: 2024-10-11 17:34:05.210906 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "014438261702" +down_revision = "320c5b39c509" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "model_provisioning", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("tool_model_id", sa.Integer(), nullable=False), + sa.Column("revision", sa.String(), nullable=False), + sa.Column("commit_hash", sa.String(), nullable=False), + sa.Column("provisioned_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["tool_model_id"], + ["models.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_model_provisioning_id"), + "model_provisioning", + ["id"], + unique=False, + ) + op.add_column( + "sessions", sa.Column("provisioning_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + None, "sessions", "model_provisioning", ["provisioning_id"], ["id"] + ) diff --git a/backend/capellacollab/alembic/versions/2f8449c217fa_add_project_tools_table.py b/backend/capellacollab/alembic/versions/2f8449c217fa_add_project_tools_table.py new file mode 100644 index 0000000000..22e28eff5b --- /dev/null +++ b/backend/capellacollab/alembic/versions/2f8449c217fa_add_project_tools_table.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add project tools table + +Revision ID: 2f8449c217fa +Revises: 014438261702 +Create Date: 2024-10-29 14:11:47.774679 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2f8449c217fa" +down_revision = "014438261702" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "project_tool_association", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("tool_version_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ), + sa.ForeignKeyConstraint( + ["tool_version_id"], + ["versions.id"], + ), + sa.PrimaryKeyConstraint("id", "project_id", "tool_version_id"), + ) diff --git a/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py b/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py index 51ab303d05..61dd9b2be6 100644 --- a/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py +++ b/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py @@ -134,9 +134,7 @@ def get_eclipse_configuration(): "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}" "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" ), - "cookies": { - "token": "{CAPELLACOLLAB_SESSION_TOKEN}", - }, + "cookies": {}, }, ] }, diff --git a/backend/capellacollab/core/database/migration.py b/backend/capellacollab/core/database/migration.py index 01c22e29bf..1c48916d99 100644 --- a/backend/capellacollab/core/database/migration.py +++ b/backend/capellacollab/core/database/migration.py @@ -168,9 +168,7 @@ def get_eclipse_session_configuration() -> ( ) + "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" ), - cookies={ - "token": "{CAPELLACOLLAB_SESSION_TOKEN}", - }, + cookies={}, sharing=tools_models.ToolSessionSharingConfiguration( enabled=True ), diff --git a/backend/capellacollab/core/database/models.py b/backend/capellacollab/core/database/models.py index c82af1b56d..8f2ac6e160 100644 --- a/backend/capellacollab/core/database/models.py +++ b/backend/capellacollab/core/database/models.py @@ -13,7 +13,9 @@ import capellacollab.projects.toolmodels.models import capellacollab.projects.toolmodels.modelsources.git.models import capellacollab.projects.toolmodels.modelsources.t4c.models +import capellacollab.projects.toolmodels.provisioning.models import capellacollab.projects.toolmodels.restrictions.models +import capellacollab.projects.tools.models import capellacollab.projects.users.models import capellacollab.sessions.models import capellacollab.settings.configuration.models diff --git a/backend/capellacollab/core/responses.py b/backend/capellacollab/core/responses.py index 343a8ab856..5caed9b193 100644 --- a/backend/capellacollab/core/responses.py +++ b/backend/capellacollab/core/responses.py @@ -210,3 +210,33 @@ class ZIPFileResponse(fastapi.responses.StreamingResponse): } } } + + +class MarkdownResponse(fastapi.responses.Response): + """Custom error class for Markdown responses. + + To use the class as response class, pass the following parameters + to the fastapi route definition. + + ```python + response_class=fastapi.responses.Response + responses=responses.MarkdownResponse.responses + ``` + + Don't use Markdown as response_class as this will also change the + media type for all error responses, see: + https://github.com/tiangolo/fastapi/discussions/6799 + + To return an Markdown response in the route, use: + + ```python + return responses.MarkdownResponse( + content=b"# Hello World", + ) + ``` + """ + + media_type = "text/markdown" + responses: dict[int | str, dict[str, t.Any]] | None = { + 200: {"content": {"text/markdown": {"schema": {"type": "string"}}}} + } diff --git a/backend/capellacollab/projects/models.py b/backend/capellacollab/projects/models.py index 461956c394..7c61e109ca 100644 --- a/backend/capellacollab/projects/models.py +++ b/backend/capellacollab/projects/models.py @@ -16,6 +16,9 @@ if t.TYPE_CHECKING: from capellacollab.projects.toolmodels.models import DatabaseToolModel + from capellacollab.projects.tools.models import ( + DatabaseProjectToolAssociation, + ) from capellacollab.projects.users.models import ProjectUserAssociation @@ -134,5 +137,8 @@ class DatabaseProject(database.Base): models: orm.Mapped[list[DatabaseToolModel]] = orm.relationship( default_factory=list, back_populates="project" ) + tools: orm.Mapped[list[DatabaseProjectToolAssociation]] = orm.relationship( + default_factory=list, back_populates="project" + ) is_archived: orm.Mapped[bool] = orm.mapped_column(default=False) diff --git a/backend/capellacollab/projects/routes.py b/backend/capellacollab/projects/routes.py index 86bdff1273..0594c8e18e 100644 --- a/backend/capellacollab/projects/routes.py +++ b/backend/capellacollab/projects/routes.py @@ -18,6 +18,7 @@ from capellacollab.projects.toolmodels.backups import core as backups_core from capellacollab.projects.toolmodels.backups import crud as backups_crud from capellacollab.projects.toolmodels.backups import models as backups_models +from capellacollab.projects.tools import routes as projects_tools_routes from capellacollab.projects.users import crud as projects_users_crud from capellacollab.projects.users import models as projects_users_models from capellacollab.projects.users import routes as projects_users_routes @@ -206,3 +207,8 @@ def _delete_all_pipelines_for_project( prefix="/{project_slug}/events", tags=["Projects - Events"], ) +router.include_router( + projects_tools_routes.router, + prefix="/{project_slug}/tools", + tags=["Projects - Tools"], +) diff --git a/backend/capellacollab/projects/toolmodels/backups/runs/interface.py b/backend/capellacollab/projects/toolmodels/backups/runs/interface.py index 29cb3d355b..8fcb674bc8 100644 --- a/backend/capellacollab/projects/toolmodels/backups/runs/interface.py +++ b/backend/capellacollab/projects/toolmodels/backups/runs/interface.py @@ -270,6 +270,7 @@ def _job_is_finished(status: models.PipelineRunStatus): def _refresh_and_trigger_pipeline_jobs(): + log.debug("Starting to refresh and trigger pipeline jobs...") _schedule_pending_jobs() with database.SessionLocal() as db: for run in crud.get_scheduled_or_running_pipelines(db): @@ -301,3 +302,4 @@ def _refresh_and_trigger_pipeline_jobs(): _terminate_job(run) db.commit() + log.debug("Finished refreshing and triggering of pipeline jobs.") diff --git a/backend/capellacollab/projects/toolmodels/crud.py b/backend/capellacollab/projects/toolmodels/crud.py index b26bf79b17..305db5ab4e 100644 --- a/backend/capellacollab/projects/toolmodels/crud.py +++ b/backend/capellacollab/projects/toolmodels/crud.py @@ -7,7 +7,7 @@ import sqlalchemy as sa from sqlalchemy import orm -from capellacollab.projects import models as projects_model +from capellacollab.projects import models as projects_models from capellacollab.tools import models as tools_models from . import models @@ -68,7 +68,7 @@ def get_model_by_slugs( .options(orm.joinedload(models.DatabaseToolModel.project)) .where( models.DatabaseToolModel.project.has( - projects_model.DatabaseProject.slug == project_slug + projects_models.DatabaseProject.slug == project_slug ) ) .where(models.DatabaseToolModel.slug == model_slug) @@ -77,7 +77,7 @@ def get_model_by_slugs( def create_model( db: orm.Session, - project: projects_model.DatabaseProject, + project: projects_models.DatabaseProject, post_model: models.PostToolModel, tool: tools_models.DatabaseTool, version: tools_models.DatabaseVersion | None = None, @@ -135,7 +135,7 @@ def update_model( name: str | None, version: tools_models.DatabaseVersion | None, nature: tools_models.DatabaseNature | None, - project: projects_model.DatabaseProject, + project: projects_models.DatabaseProject, display_order: int | None, ) -> models.DatabaseToolModel: model.version = version diff --git a/backend/capellacollab/projects/toolmodels/modelbadge/exceptions.py b/backend/capellacollab/projects/toolmodels/modelbadge/exceptions.py index 9b37f7528a..ea26301d97 100644 --- a/backend/capellacollab/projects/toolmodels/modelbadge/exceptions.py +++ b/backend/capellacollab/projects/toolmodels/modelbadge/exceptions.py @@ -13,7 +13,7 @@ def __init__(self): title="Model complexity badge not configured properly", reason=( "The model complexity badge is not configured properly. " - "Please contact your diagram cache administrator." + "Please contact your project admin or system administrator." ), err_code="MODEL_COMPLEXITY_BADGE_NOT_CONFIGURED_PROPERLY", ) diff --git a/backend/capellacollab/projects/toolmodels/models.py b/backend/capellacollab/projects/toolmodels/models.py index 1eb94bdc16..ac9a057a64 100644 --- a/backend/capellacollab/projects/toolmodels/models.py +++ b/backend/capellacollab/projects/toolmodels/models.py @@ -37,6 +37,7 @@ DatabaseVersion, ) + from .provisioning.models import DatabaseModelProvisioning from .restrictions.models import DatabaseToolModelRestrictions @@ -124,6 +125,14 @@ class DatabaseToolModel(database.Base): ) ) + provisioning: orm.Mapped[list[DatabaseModelProvisioning]] = ( + orm.relationship( + back_populates="tool_model", + cascade="delete", + default_factory=list, + ) + ) + class ToolModel(core_pydantic.BaseModel): id: int @@ -144,3 +153,10 @@ class SimpleToolModel(core_pydantic.BaseModel): slug: str name: str project: projects_models.SimpleProject + + +class SimpleToolModelWithoutProject(core_pydantic.BaseModel): + id: int + slug: str + name: str + git_models: list[GitModel] | None = None diff --git a/backend/capellacollab/projects/toolmodels/modelsources/git/routes.py b/backend/capellacollab/projects/toolmodels/modelsources/git/routes.py index 42339c2153..d5d7a0d190 100644 --- a/backend/capellacollab/projects/toolmodels/modelsources/git/routes.py +++ b/backend/capellacollab/projects/toolmodels/modelsources/git/routes.py @@ -14,7 +14,7 @@ from capellacollab.projects.toolmodels import models as toolmodels_models from capellacollab.projects.toolmodels.backups import crud as backups_crud from capellacollab.projects.users import models as projects_users_models -from capellacollab.settings.modelsources.git import core as git_core +from capellacollab.settings.modelsources.git import core as instances_git_core from capellacollab.settings.modelsources.git import models as git_models from capellacollab.settings.modelsources.git import util as git_util @@ -69,7 +69,7 @@ async def get_revisions_of_primary_git_model( injectables.get_existing_primary_git_model ), ) -> git_models.GetRevisionsResponseModel: - return await git_core.get_remote_refs( + return await instances_git_core.get_remote_refs( primary_git_model.path, primary_git_model.username, primary_git_model.password, @@ -94,7 +94,7 @@ async def get_revisions_with_model_credentials( injectables.get_existing_git_model ), ): - return await git_core.get_remote_refs( + return await instances_git_core.get_remote_refs( url, git_model.username, git_model.password ) diff --git a/backend/capellacollab/projects/toolmodels/provisioning/__init__.py b/backend/capellacollab/projects/toolmodels/provisioning/__init__.py new file mode 100644 index 0000000000..04412280d8 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/projects/toolmodels/provisioning/crud.py b/backend/capellacollab/projects/toolmodels/provisioning/crud.py new file mode 100644 index 0000000000..d82a393850 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/crud.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.users import models as users_models + +from . import models + + +def create_model_provisioning( + db: orm.Session, model: models.DatabaseModelProvisioning +) -> models.DatabaseModelProvisioning: + db.add(model) + db.commit() + return model + + +def get_model_provisioning( + db: orm.Session, + tool_model: toolmodels_models.DatabaseToolModel, + user: users_models.DatabaseUser, +) -> models.DatabaseModelProvisioning | None: + return db.execute( + sa.select(models.DatabaseModelProvisioning) + .where(models.DatabaseModelProvisioning.tool_model == tool_model) + .where(models.DatabaseModelProvisioning.user == user) + ).scalar_one_or_none() + + +def delete_model_provisioning( + db: orm.Session, provisioning: models.DatabaseModelProvisioning +): + db.delete(provisioning) + db.commit() diff --git a/backend/capellacollab/projects/toolmodels/provisioning/exceptions.py b/backend/capellacollab/projects/toolmodels/provisioning/exceptions.py new file mode 100644 index 0000000000..2ba4c5dbff --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/exceptions.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from fastapi import status + +from capellacollab.core import exceptions as core_exceptions + + +class ProvisioningNotFoundError(core_exceptions.BaseError): + def __init__(self, project_slug: str, model_slug: str): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + title="Provisioning not found", + reason=f"Couldn't find a provisioning for the model '{model_slug}' in the project '{project_slug}'.", + err_code="PROVISIONING_NOT_FOUND", + ) diff --git a/backend/capellacollab/projects/toolmodels/provisioning/injectables.py b/backend/capellacollab/projects/toolmodels/provisioning/injectables.py new file mode 100644 index 0000000000..c1802084e4 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/injectables.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models + +from .. import injectables as toolmodels_injectables +from .. import models as toolmodels_models +from . import crud, models + + +def get_model_provisioning( + model: toolmodels_models.DatabaseToolModel = fastapi.Depends( + toolmodels_injectables.get_existing_capella_model + ), + current_user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), + db: orm.Session = fastapi.Depends(database.get_db), +) -> models.DatabaseModelProvisioning | None: + return crud.get_model_provisioning(db, tool_model=model, user=current_user) diff --git a/backend/capellacollab/projects/toolmodels/provisioning/models.py b/backend/capellacollab/projects/toolmodels/provisioning/models.py new file mode 100644 index 0000000000..1bd75d5c23 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/models.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime + +import pydantic +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core import pydantic as core_pydantic +from capellacollab.projects.toolmodels import ( + models as projects_toolmodels_models, +) +from capellacollab.sessions import models as sessions_models +from capellacollab.users import models as users_models + + +class ModelProvisioning(core_pydantic.BaseModel): + session: sessions_models.Session | None + provisioned_at: datetime.datetime + revision: str + commit_hash: str + + _validate_trigger_time = pydantic.field_serializer("provisioned_at")( + core_pydantic.datetime_serializer + ) + + +class DatabaseModelProvisioning(database.Base): + __tablename__ = "model_provisioning" + + id: orm.Mapped[int] = orm.mapped_column( + init=False, primary_key=True, index=True + ) + + user_id: orm.Mapped[int] = orm.mapped_column( + sa.ForeignKey("users.id"), + init=False, + ) + user: orm.Mapped[users_models.DatabaseUser] = orm.relationship( + foreign_keys=[user_id] + ) + + tool_model_id: orm.Mapped[int] = orm.mapped_column( + sa.ForeignKey("models.id"), + init=False, + ) + tool_model: orm.Mapped[projects_toolmodels_models.DatabaseToolModel] = ( + orm.relationship( + foreign_keys=[tool_model_id], + ) + ) + + revision: orm.Mapped[str] + commit_hash: orm.Mapped[str] + + provisioned_at: orm.Mapped[datetime.datetime] = orm.mapped_column( + default=datetime.datetime.now(datetime.UTC) + ) + + session: orm.Mapped[sessions_models.DatabaseSession | None] = ( + orm.relationship( + uselist=False, back_populates="provisioning", default=None + ) + ) diff --git a/backend/capellacollab/projects/toolmodels/provisioning/routes.py b/backend/capellacollab/projects/toolmodels/provisioning/routes.py new file mode 100644 index 0000000000..44ee7152b9 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/provisioning/routes.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.projects import injectables as projects_injectables +from capellacollab.projects import models as projects_models +from capellacollab.projects.toolmodels import ( + injectables as toolmodels_injectables, +) +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.projects.users import models as projects_users_models + +from . import crud, exceptions, injectables, models + +router = fastapi.APIRouter( + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.USER + ) + ) + ], +) + + +@router.get("", response_model=models.ModelProvisioning | None) +def get_provisioning( + provisioning: models.DatabaseModelProvisioning = fastapi.Depends( + injectables.get_model_provisioning + ), +) -> models.DatabaseModelProvisioning: + return provisioning + + +@router.delete("", status_code=204) +def reset_provisioning( + provisioning: models.DatabaseModelProvisioning | None = fastapi.Depends( + injectables.get_model_provisioning + ), + model: toolmodels_models.DatabaseToolModel = fastapi.Depends( + toolmodels_injectables.get_existing_capella_model + ), + project: projects_models.DatabaseProject = fastapi.Depends( + projects_injectables.get_existing_project + ), + db: orm.Session = fastapi.Depends(database.get_db), +): + """This will delete the provisioning data from the workspace. + During the next session request, the existing provisioning will be overwritten in the workspace. + """ + if not provisioning: + raise exceptions.ProvisioningNotFoundError( + project_slug=project.slug, model_slug=model.slug + ) + + crud.delete_model_provisioning(db, provisioning) diff --git a/backend/capellacollab/projects/toolmodels/readme/routes.py b/backend/capellacollab/projects/toolmodels/readme/routes.py new file mode 100644 index 0000000000..ca0b16be63 --- /dev/null +++ b/backend/capellacollab/projects/toolmodels/readme/routes.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging + +import fastapi + +import capellacollab.projects.toolmodels.modelsources.git.injectables as git_injectables +from capellacollab.core import logging as log +from capellacollab.core import responses +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.projects.toolmodels.modelsources.git.handler import handler +from capellacollab.projects.users import models as projects_users_models + +router = fastapi.APIRouter( + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.USER + ) + ) + ], +) + + +@router.get( + "", + response_class=fastapi.responses.Response, + responses=responses.MarkdownResponse.responses, +) +async def get_readme( + git_handler: handler.GitHandler = fastapi.Depends( + git_injectables.get_git_handler + ), + logger: logging.LoggerAdapter = fastapi.Depends(log.get_request_logger), +): + _, file = await git_handler.get_file("README.md", logger, None) + return responses.MarkdownResponse(content=file) diff --git a/backend/capellacollab/projects/toolmodels/routes.py b/backend/capellacollab/projects/toolmodels/routes.py index 714d980d14..2c05586b4b 100644 --- a/backend/capellacollab/projects/toolmodels/routes.py +++ b/backend/capellacollab/projects/toolmodels/routes.py @@ -23,6 +23,8 @@ from .diagrams import routes as diagrams_routes from .modelbadge import routes as complexity_badge_routes from .modelsources import routes as modelsources_routes +from .provisioning import routes as provisioning_routes +from .readme import routes as readme_routes from .restrictions import routes as restrictions_routes router = fastapi.APIRouter( @@ -269,3 +271,13 @@ def raise_if_model_exists_in_project( prefix="/{model_slug}/badges/complexity", tags=["Projects - Models - Model complexity badge"], ) +router.include_router( + provisioning_routes.router, + prefix="/{model_slug}/provisioning", + tags=["Projects - Models - Provisioning"], +) +router.include_router( + readme_routes.router, + prefix="/{model_slug}/readme", + tags=["Projects - Models - README"], +) diff --git a/backend/capellacollab/projects/tools/__init__.py b/backend/capellacollab/projects/tools/__init__.py new file mode 100644 index 0000000000..04412280d8 --- /dev/null +++ b/backend/capellacollab/projects/tools/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/projects/tools/crud.py b/backend/capellacollab/projects/tools/crud.py new file mode 100644 index 0000000000..287f453927 --- /dev/null +++ b/backend/capellacollab/projects/tools/crud.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.projects import models as projects_models +from capellacollab.tools import models as tools_models + +from . import models + + +def create_project_tool( + db: orm.Session, + project: projects_models.DatabaseProject, + tool_version: tools_models.DatabaseVersion, +) -> models.DatabaseProjectToolAssociation: + project_tool = models.DatabaseProjectToolAssociation( + project=project, tool_version=tool_version + ) + db.add(project_tool) + db.commit() + db.refresh(project_tool) + return project_tool + + +def get_project_tool_by_id( + db: orm.Session, + project_tool_id: int, +) -> models.DatabaseProjectToolAssociation | None: + return db.execute( + sa.select(models.DatabaseProjectToolAssociation).where( + models.DatabaseProjectToolAssociation.id == project_tool_id + ) + ).scalar_one_or_none() + + +def get_project_tool_by_project_and_tool_version( + db: orm.Session, + project: projects_models.DatabaseProject, + tool_version: tools_models.DatabaseVersion, +) -> models.DatabaseProjectToolAssociation | None: + return db.execute( + sa.select(models.DatabaseProjectToolAssociation) + .where( + models.DatabaseProjectToolAssociation.tool_version == tool_version + ) + .where(models.DatabaseProjectToolAssociation.project == project) + ).scalar_one_or_none() + + +def delete_project_tool( + db: orm.Session, project_tool: models.DatabaseProjectToolAssociation +) -> None: + db.delete(project_tool) + db.commit() diff --git a/backend/capellacollab/projects/tools/exceptions.py b/backend/capellacollab/projects/tools/exceptions.py new file mode 100644 index 0000000000..0fb894ccf6 --- /dev/null +++ b/backend/capellacollab/projects/tools/exceptions.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from fastapi import status + +from capellacollab.core import exceptions as core_exceptions + + +class ProjectToolBelongsToOtherProject(core_exceptions.BaseError): + def __init__(self, project_tool_id: int, project_slug: str): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="The project tool belongs to another project", + reason=f"The project tool with ID {project_tool_id} doesn't belong to the project '{project_slug}'.", + err_code="PROJECT_TOOL_DOES_NOT_BELONG_TO_PROJECT", + ) + + +class ProjectToolNotFound(core_exceptions.BaseError): + def __init__(self, project_tool_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + title="Tool not found in project", + reason=f"The project tool with ID {project_tool_id} was not found.", + err_code="PROJECT_TOOL_NOT_FOUND", + ) + + +class ToolAlreadyLinkedToProjectError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + title="Tool already linked to project", + reason="The specific version of the tool is already linked to the project.", + err_code="TOOL_ALREADY_EXISTS_IN_PROJECT", + ) diff --git a/backend/capellacollab/projects/tools/injectables.py b/backend/capellacollab/projects/tools/injectables.py new file mode 100644 index 0000000000..139d0c3c19 --- /dev/null +++ b/backend/capellacollab/projects/tools/injectables.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.projects import injectables as projects_injectables +from capellacollab.projects import models as projects_models + +from . import crud, exceptions, models + + +def get_existing_project_tool( + project_tool_id: int, + db: orm.Session = fastapi.Depends(database.get_db), + project: projects_models.DatabaseProject = fastapi.Depends( + projects_injectables.get_existing_project + ), +) -> models.DatabaseProjectToolAssociation: + project_tool = crud.get_project_tool_by_id(db, project_tool_id) + if not project_tool: + raise exceptions.ProjectToolNotFound(project_tool_id) + if project_tool.project != project: + raise exceptions.ProjectToolBelongsToOtherProject( + project_tool_id, project.slug + ) + return project_tool diff --git a/backend/capellacollab/projects/tools/models.py b/backend/capellacollab/projects/tools/models.py new file mode 100644 index 0000000000..87a9b7a828 --- /dev/null +++ b/backend/capellacollab/projects/tools/models.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +import typing as t + +import pydantic +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core import pydantic as core_pydantic +from capellacollab.projects.toolmodels import models as toolsmodels_models +from capellacollab.tools import models as tools_models + +if t.TYPE_CHECKING: + from capellacollab.projects.models import DatabaseProject + from capellacollab.tools.models import DatabaseVersion + + +class ProjectTool(core_pydantic.BaseModel): + id: int | None + + tool_version: tools_models.SimpleToolVersion + tool: tools_models.Tool + used_by: list[toolsmodels_models.SimpleToolModelWithoutProject] = [] + + @pydantic.model_validator(mode="before") + @classmethod + def derive_tool_from_version(cls, data: t.Any) -> t.Any: + if not isinstance(data, DatabaseProjectToolAssociation): + return data + + data_dict = data.__dict__ + data_dict["tool"] = data.tool_version.tool + return data_dict + + +class PostProjectToolRequest(core_pydantic.BaseModel): + tool_id: int + tool_version_id: int + + +class DatabaseProjectToolAssociation(database.Base): + __tablename__ = "project_tool_association" + + id: orm.Mapped[int] = orm.mapped_column( + sa.Integer, + init=False, + primary_key=True, + autoincrement=True, + ) + + project_id: orm.Mapped[int] = orm.mapped_column( + sa.ForeignKey("projects.id"), primary_key=True, init=False + ) + project: orm.Mapped["DatabaseProject"] = orm.relationship( + back_populates="tools" + ) + + tool_version_id: orm.Mapped[int] = orm.mapped_column( + sa.ForeignKey("versions.id"), primary_key=True, init=False + ) + tool_version: orm.Mapped["DatabaseVersion"] = orm.relationship() diff --git a/backend/capellacollab/projects/tools/routes.py b/backend/capellacollab/projects/tools/routes.py new file mode 100644 index 0000000000..8bb3570df3 --- /dev/null +++ b/backend/capellacollab/projects/tools/routes.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.projects import injectables as projects_injectables +from capellacollab.projects import models as projects_models +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.projects.users import models as projects_users_models +from capellacollab.tools import injectables as tools_injectables +from capellacollab.tools import models as tools_models + +from . import crud, exceptions, injectables, models + +router = fastapi.APIRouter( + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.USER + ) + ) + ] +) + + +@router.get( + "", +) +def get_project_tools( + project: projects_models.DatabaseProject = fastapi.Depends( + projects_injectables.get_existing_project + ), +) -> list[models.ProjectTool]: + tools = [models.ProjectTool.model_validate(tool) for tool in project.tools] + + for model in project.models: + if not model.version: + continue + + tool = next( + ( + tool + for tool in tools + if model.version.id == tool.tool_version.id + ), + None, + ) + + if not tool: + tool = models.ProjectTool( + id=None, + tool_version=tools_models.SimpleToolVersion.model_validate( + model.version + ), + tool=tools_models.Tool.model_validate(model.version.tool), + used_by=[], + ) + tools.append(tool) + + tool.used_by.append( + toolmodels_models.SimpleToolModelWithoutProject.model_validate( + model + ) + ) + + return tools + + +@router.post( + "", + response_model=models.ProjectTool, + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.MANAGER + ) + ) + ], +) +def link_tool_to_project( + body: models.PostProjectToolRequest, + db: orm.Session = fastapi.Depends(database.get_db), + project: projects_models.DatabaseProject = fastapi.Depends( + projects_injectables.get_existing_project + ), +) -> models.DatabaseProjectToolAssociation: + tool_version = tools_injectables.get_existing_tool_version( + body.tool_id, body.tool_version_id, db + ) + if crud.get_project_tool_by_project_and_tool_version( + db, project, tool_version + ): + raise exceptions.ToolAlreadyLinkedToProjectError() + return crud.create_project_tool(db, project, tool_version) + + +@router.delete( + "/{project_tool_id}", + status_code=204, + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.MANAGER + ) + ) + ], +) +def delete_tool_from_project( + db: orm.Session = fastapi.Depends(database.get_db), + project_tool: models.DatabaseProjectToolAssociation = fastapi.Depends( + injectables.get_existing_project_tool + ), +) -> None: + crud.delete_project_tool(db, project_tool) diff --git a/backend/capellacollab/sessions/exceptions.py b/backend/capellacollab/sessions/exceptions.py index 938f8b61ad..3291ca4801 100644 --- a/backend/capellacollab/sessions/exceptions.py +++ b/backend/capellacollab/sessions/exceptions.py @@ -115,6 +115,22 @@ def __init__( ) +class ProjectAndModelMismatchError(core_exceptions.BaseError): + def __init__( + self, + project_slug: str, + model_name: str, + ): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + title="Mismatch between project scope and provisioning", + reason=( + f"The model '{model_name}' doesn't belong to the project '{project_slug}'." + ), + err_code="MODEL_PROJECT_MISMATCH", + ) + + class InvalidConnectionMethodIdentifierError(core_exceptions.BaseError): def __init__( self, @@ -166,3 +182,23 @@ def __init__(self): reason="Provisioning is not supported for persistent sessions.", err_code="PROVISIONING_UNSUPPORTED", ) + + +class ProvisioningRequiredError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + title="Provisioning is required for this tool", + reason="Provisioning is required for persistent sessions of the selected tool.", + err_code="PROVISIONING_REQUIRED", + ) + + +class ProjectScopeRequiredError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + title="A project scope is required.", + reason="Persistent provisioning requires a project scope.", + err_code="PROJECT_SCOPE_REQUIRED", + ) diff --git a/backend/capellacollab/sessions/hooks/__init__.py b/backend/capellacollab/sessions/hooks/__init__.py index 09d7de7904..bfb3bfbbf7 100644 --- a/backend/capellacollab/sessions/hooks/__init__.py +++ b/backend/capellacollab/sessions/hooks/__init__.py @@ -11,6 +11,7 @@ jupyter, networking, persistent_workspace, + project_scope, provisioning, pure_variants, read_only_workspace, @@ -29,6 +30,7 @@ "guacamole": guacamole.GuacamoleIntegration(), "http": http.HTTPIntegration(), "read_only_hook": read_only_workspace.ReadOnlyWorkspaceHook(), + "project_scope": project_scope.ProjectScopeHook(), "provisioning": provisioning.ProvisionWorkspaceHook(), "session_preparation": session_preparation.GitRepositoryCloningHook(), "networking": networking.NetworkingIntegration(), diff --git a/backend/capellacollab/sessions/hooks/authentication.py b/backend/capellacollab/sessions/hooks/authentication.py index 16b0aaf2f3..5a8d4a564c 100644 --- a/backend/capellacollab/sessions/hooks/authentication.py +++ b/backend/capellacollab/sessions/hooks/authentication.py @@ -13,18 +13,16 @@ class PreAuthenticationHook(interface.HookRegistration): - def session_connection_hook( # type: ignore[override] + def session_connection_hook( self, - db_session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - **kwargs, + request: interface.SessionConnectionHookRequest, ) -> interface.SessionConnectionHookResult: """Issue pre-authentication tokens for sessions""" return interface.SessionConnectionHookResult( cookies={ "ccm_session_token": self._issue_session_token( - user, db_session + request.user, request.db_session ) } ) diff --git a/backend/capellacollab/sessions/hooks/guacamole.py b/backend/capellacollab/sessions/hooks/guacamole.py index dce8f001c6..4ea12c157c 100644 --- a/backend/capellacollab/sessions/hooks/guacamole.py +++ b/backend/capellacollab/sessions/hooks/guacamole.py @@ -12,9 +12,6 @@ from capellacollab.config import config from capellacollab.core import credentials -from capellacollab.sessions import models as sessions_models -from capellacollab.sessions.operators import k8s -from capellacollab.tools import models as tools_models from . import interface @@ -40,14 +37,11 @@ class GuacamoleIntegration(interface.HookRegistration): "https": None, } - def post_session_creation_hook( # type: ignore[override] + def post_session_creation_hook( self, - session: k8s.Session, - db_session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + request: interface.PostSessionCreationHookRequest, ) -> interface.PostSessionCreationHookResult: - if connection_method.type != "guacamole": + if request.connection_method.type != "guacamole": return interface.PostSessionCreationHookResult() guacamole_username = credentials.generate_password() @@ -60,9 +54,9 @@ def post_session_creation_hook( # type: ignore[override] guacamole_identifier = self._create_connection( guacamole_token, - db_session.environment["CAPELLACOLLAB_SESSION_TOKEN"], - session["host"], - session["port"], + request.db_session.environment["CAPELLACOLLAB_SESSION_TOKEN"], + request.session["host"], + request.session["port"], )["identifier"] self._assign_user_to_connection( @@ -79,16 +73,14 @@ def post_session_creation_hook( # type: ignore[override] config=guacamole_config, ) - def session_connection_hook( # type: ignore[override] + def session_connection_hook( self, - db_session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + request: interface.SessionConnectionHookRequest, ) -> interface.SessionConnectionHookResult: - if connection_method.type != "guacamole": + if request.connection_method.type != "guacamole": return interface.SessionConnectionHookResult() - session_config = db_session.config + session_config = request.db_session.config if not session_config or not session_config.get("guacamole_username"): return interface.SessionConnectionHookResult() @@ -102,16 +94,13 @@ def session_connection_hook( # type: ignore[override] redirect_url=config.extensions.guacamole.public_uri + "/#/", ) - def pre_session_termination_hook( # type: ignore[override] - self, - session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + def pre_session_termination_hook( + self, request: interface.PreSessionTerminationHookRequest ) -> interface.PreSessionTerminationHookResult: - if connection_method.type != "guacamole": + if request.connection_method.type != "guacamole": return interface.SessionConnectionHookResult() - session_config = session.config + session_config = request.session.config if session_config and session_config.get("guacamole_username"): guacamole_token = self._get_admin_token() diff --git a/backend/capellacollab/sessions/hooks/http.py b/backend/capellacollab/sessions/hooks/http.py index aae9953021..e97b36b15b 100644 --- a/backend/capellacollab/sessions/hooks/http.py +++ b/backend/capellacollab/sessions/hooks/http.py @@ -1,35 +1,28 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -import logging - from capellacollab.core import models as core_models from capellacollab.tools import models as tools_models -from .. import models as sessions_models from .. import util as sessions_util from . import interface class HTTPIntegration(interface.HookRegistration): - def session_connection_hook( # type: ignore[override] - self, - db_session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - logger: logging.LoggerAdapter, - **kwargs, + def session_connection_hook( + self, request: interface.SessionConnectionHookRequest ) -> interface.SessionConnectionHookResult: if not isinstance( - connection_method, tools_models.HTTPConnectionMethod + request.connection_method, tools_models.HTTPConnectionMethod ): return interface.SessionConnectionHookResult() try: - redirect_url = connection_method.redirect_url.format( - **db_session.environment + redirect_url = request.connection_method.redirect_url.format( + **request.db_session.environment ) except Exception: - logger.error( + request.logger.error( "Error while formatting the redirect URL", exc_info=True ) return interface.SessionConnectionHookResult( @@ -43,7 +36,9 @@ def session_connection_hook( # type: ignore[override] ) cookies, warnings = sessions_util.resolve_environment_variables( - logger, db_session.environment, connection_method.cookies + request.logger, + request.db_session.environment, + request.connection_method.cookies, ) return interface.SessionConnectionHookResult( diff --git a/backend/capellacollab/sessions/hooks/interface.py b/backend/capellacollab/sessions/hooks/interface.py index fefbd60f4a..dced782700 100644 --- a/backend/capellacollab/sessions/hooks/interface.py +++ b/backend/capellacollab/sessions/hooks/interface.py @@ -2,12 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 import abc +import dataclasses import logging import typing as t from sqlalchemy import orm from capellacollab.core import models as core_models +from capellacollab.projects import models as projects_models from capellacollab.sessions import operators from capellacollab.sessions.operators import k8s from capellacollab.sessions.operators import models as operators_models @@ -17,6 +19,44 @@ from .. import models as sessions_models +@dataclasses.dataclass() +class ConfigurationHookRequest: + """Request type of the configuration hook + + Attributes + ---------- + db : sqlalchemy.orm.Session + Database session. Can be used to access the database + operator : operators.KubernetesOperator + Operator, which is used to spawn the session + user : users_models.DatabaseUser + User who has requested the session + tool : tools_models.DatabaseTool + Tool of the requested session + tool_version : tools_models.DatabaseVersion + Tool version of the requested session + session_type : sessions_models.SessionType + Type of the session (persistent, read-only, etc.) + connection_method : tools_models.ToolSessionConnectionMethod + Requested connection method for the session + provisioning : list[sessions_models.SessionProvisioningRequest] + List of workspace provisioning requests + session_id: str + ID of the session to be created + """ + + db: orm.Session + operator: operators.KubernetesOperator + user: users_models.DatabaseUser + tool: tools_models.DatabaseTool + tool_version: tools_models.DatabaseVersion + session_type: sessions_models.SessionType + connection_method: tools_models.ToolSessionConnectionMethod + provisioning: list[sessions_models.SessionProvisioningRequest] + project_scope: projects_models.DatabaseProject | None + session_id: str + + class ConfigurationHookResult(t.TypedDict): """Return type of the configuration hook @@ -42,6 +82,34 @@ class ConfigurationHookResult(t.TypedDict): init_environment: t.NotRequired[t.Mapping] +@dataclasses.dataclass() +class PostSessionCreationHookRequest: + """Request type of the post session creation hook + + Attributes + ---------- + session_id : str + ID of the session + session : k8s.Session + Session object (contains connection information) + db_session : sessions_models.DatabaseSession + Collaboration Manager session in the database + operator : operators.KubernetesOperator + Operator, which is used to spawn the session + user : users_models.DatabaseUser + User who has requested the session + connection_method : tools_models.ToolSessionConnectionMethod + Requested connection method for the session + """ + + session_id: str + session: k8s.Session + db_session: sessions_models.DatabaseSession + operator: operators.KubernetesOperator + user: users_models.DatabaseUser + connection_method: tools_models.ToolSessionConnectionMethod + + class PostSessionCreationHookResult(t.TypedDict): """Return type of the post session creation hook @@ -55,6 +123,31 @@ class PostSessionCreationHookResult(t.TypedDict): config: t.NotRequired[t.Mapping] +@dataclasses.dataclass() +class SessionConnectionHookRequest: + """Request type of the session connection hook + + Attributes + ---------- + db : sqlalchemy.orm.Session + Database session. Can be used to access the database + db_session : sessions_models.DatabaseSession + Collaboration Manager session in the database + connection_method : tools_models.ToolSessionConnectionMethod + Connection method of the session + logger : logging.LoggerAdapter + Logger for the specific request + user : users_models.DatabaseUser + User who is connecting to the session + """ + + db: orm.Session + db_session: sessions_models.DatabaseSession + connection_method: tools_models.ToolSessionConnectionMethod + logger: logging.LoggerAdapter + user: users_models.DatabaseUser + + class SessionConnectionHookResult(t.TypedDict): """Return type of the session connection hook @@ -80,6 +173,28 @@ class SessionConnectionHookResult(t.TypedDict): warnings: t.NotRequired[list[core_models.Message]] +@dataclasses.dataclass() +class PreSessionTerminationHookRequest: + """Request type of the pre session termination hook + + Attributes + ---------- + db : sqlalchemy.orm.Session + Database session. Can be used to access the database + operator : operators.KubernetesOperator + Operator, which is used to spawn the session + session : sessions_models.DatabaseSession + Session which is to be terminated + connection_method : tools_models.ToolSessionConnectionMethod + Connection method of the session + """ + + db: orm.Session + operator: operators.KubernetesOperator + session: sessions_models.DatabaseSession + connection_method: tools_models.ToolSessionConnectionMethod + + class PreSessionTerminationHookResult(t.TypedDict): """Return type of the pre session termination hook""" @@ -101,143 +216,58 @@ class HookRegistration(metaclass=abc.ABCMeta): # pylint: disable=unused-argument def configuration_hook( - self, - db: orm.Session, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - tool: tools_models.DatabaseTool, - tool_version: tools_models.DatabaseVersion, - session_type: sessions_models.SessionType, - connection_method: tools_models.ToolSessionConnectionMethod, - provisioning: list[sessions_models.SessionProvisioningRequest], - session_id: str, - **kwargs, + self, request: ConfigurationHookRequest ) -> ConfigurationHookResult: """Hook to determine session configuration This hook is executed before the creation of persistent sessions. + """ - Parameters - ---------- - db : sqlalchemy.orm.Session - Database session. Can be used to access the database - operator : operators.KubernetesOperator - Operator, which is used to spawn the session - user : users_models.DatabaseUser - User who has requested the session - tool : tools_models.DatabaseTool - Tool of the requested session - tool_version : tools_models.DatabaseVersion - Tool version of the requested session - session_type : sessions_models.SessionType - Type of the session (persistent, read-only, etc.) - connection_method : tools_models.ToolSessionConnectionMethod - Requested connection method for the session - provisioning : list[sessions_models.SessionProvisioningRequest] - List of workspace provisioning requests - session_id: str - ID of the session to be created - Returns - ------- - result : ConfigurationHookResult + return ConfigurationHookResult() + + # pylint: disable=unused-argument + async def async_configuration_hook( + self, request: ConfigurationHookRequest + ) -> ConfigurationHookResult: + """Hook to determine session configuration + + Same as configuration_hook, but async. """ return ConfigurationHookResult() + # pylint: disable=unused-argument def post_session_creation_hook( self, - session_id: str, - session: k8s.Session, - db_session: sessions_models.DatabaseSession, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + request: PostSessionCreationHookRequest, ) -> PostSessionCreationHookResult: """Hook executed after session creation This hook is executed after a persistent session was created by the operator. - - Parameters - ---------- - session_id : str - ID of the session - session : k8s.Session - Session object (contains connection information) - db_session : sessions_models.DatabaseSession - Collaboration Manager session in the database - operator : operators.KubernetesOperator - Operator, which is used to spawn the session - user : users_models.DatabaseUser - User who has requested the session - connection_method : tools_models.ToolSessionConnectionMethod - Requested connection method for the session - - Returns - ------- - result : PostSessionCreationHookResult """ return PostSessionCreationHookResult() # pylint: disable=unused-argument def session_connection_hook( - self, - db: orm.Session, - db_session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - logger: logging.LoggerAdapter, - **kwargs, + self, request: SessionConnectionHookRequest ) -> SessionConnectionHookResult: """Hook executed while connecting to a session The hook is executed each time the GET `/sessions/{session_id}/connection` endpoint is called. - - Parameters - ---------- - db : sqlalchemy.orm.Session - Database session. Can be used to access the database - db_session : sessions_models.DatabaseSession - Collaboration Manager session in the database - connection_method : tools_models.ToolSessionConnectionMethod - Connection method of the session - logger : logging.LoggerAdapter - Logger for the specific request - Returns - ------- - result : SessionConnectionHookResult """ return SessionConnectionHookResult() + # pylint: disable=unused-argument def pre_session_termination_hook( - self, - db: orm.Session, - operator: operators.KubernetesOperator, - session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + self, request: PreSessionTerminationHookRequest ) -> PreSessionTerminationHookResult: """Hook executed directly before session termination This hook is executed before a read-only or persistent session is terminated by the operator. - - Parameters - ---------- - db : sqlalchemy.orm.Session - Database session. Can be used to access the database - operator : operators.KubernetesOperator - Operator, which is used to spawn the session - session : sessions_models.DatabaseSession - Session which is to be terminated - connection_method : tools_models.ToolSessionConnectionMethod - Connection method of the session - - Returns - ------- - result : PreSessionTerminationHookResult """ return PreSessionTerminationHookResult() diff --git a/backend/capellacollab/sessions/hooks/jupyter.py b/backend/capellacollab/sessions/hooks/jupyter.py index 4e0fa7050a..5e208291da 100644 --- a/backend/capellacollab/sessions/hooks/jupyter.py +++ b/backend/capellacollab/sessions/hooks/jupyter.py @@ -14,7 +14,6 @@ from capellacollab.sessions import operators from capellacollab.sessions.operators import models as operators_models from capellacollab.tools import models as tools_models -from capellacollab.users import models as users_models from . import interface @@ -22,16 +21,12 @@ class JupyterIntegration(interface.HookRegistration): - def configuration_hook( # type: ignore[override] + def configuration_hook( self, - db: orm.Session, - user: users_models.DatabaseUser, - tool: tools_models.DatabaseTool, - operator: operators.KubernetesOperator, - **kwargs, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: volumes, warnings = self._get_project_share_volume_mounts( - db, user.name, tool, operator + request.db, request.user.name, request.tool, request.operator ) return interface.ConfigurationHookResult( volumes=volumes, warnings=warnings diff --git a/backend/capellacollab/sessions/hooks/networking.py b/backend/capellacollab/sessions/hooks/networking.py index fb0a36ca6e..2561d68564 100644 --- a/backend/capellacollab/sessions/hooks/networking.py +++ b/backend/capellacollab/sessions/hooks/networking.py @@ -2,37 +2,26 @@ # SPDX-License-Identifier: Apache-2.0 -from capellacollab.sessions import operators -from capellacollab.users import models as users_models - -from .. import models as sessions_models from . import interface class NetworkingIntegration(interface.HookRegistration): """Allow sessions of the same user to talk to each other.""" - def post_session_creation_hook( # type: ignore - self, - session_id: str, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - **kwargs, + def post_session_creation_hook( + self, request: interface.PostSessionCreationHookRequest ) -> interface.PostSessionCreationHookResult: """Allow sessions of the user to talk to each other.""" - operator.create_network_policy_from_pod_to_label( - session_id, - {"capellacollab/session-id": session_id}, - {"capellacollab/owner-id": str(user.id)}, + request.operator.create_network_policy_from_pod_to_label( + request.session_id, + {"capellacollab/session-id": request.session_id}, + {"capellacollab/owner-id": str(request.user.id)}, ) return interface.PostSessionCreationHookResult() - def pre_session_termination_hook( # type: ignore - self, - operator: operators.KubernetesOperator, - session: sessions_models.DatabaseSession, - **kwargs, + def pre_session_termination_hook( + self, request: interface.PreSessionTerminationHookRequest ): - operator.delete_network_policy(session.id) + request.operator.delete_network_policy(request.session.id) diff --git a/backend/capellacollab/sessions/hooks/persistent_workspace.py b/backend/capellacollab/sessions/hooks/persistent_workspace.py index 5086f57a79..98e7ab37ed 100644 --- a/backend/capellacollab/sessions/hooks/persistent_workspace.py +++ b/backend/capellacollab/sessions/hooks/persistent_workspace.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import pathlib -import typing as t import uuid from sqlalchemy import orm @@ -19,32 +18,25 @@ from . import interface -class PersistentWorkspacEnvironment(t.TypedDict): - pass - - class PersistentWorkspaceHook(interface.HookRegistration): """Takes care of the persistent workspace of a user. Is responsible for mounting the persistent workspace into persistent sessions. """ - def configuration_hook( # type: ignore + def configuration_hook( self, - db: orm.Session, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - session_type: sessions_models.SessionType, - tool: tools_models.DatabaseTool, - **kwargs, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: - if session_type == sessions_models.SessionType.READONLY: + if request.session_type == sessions_models.SessionType.READONLY: # Skip read-only sessions, no persistent workspace needed. return interface.ConfigurationHookResult() - self._check_that_persistent_workspace_is_allowed(tool) + self._check_that_persistent_workspace_is_allowed(request.tool) - volume_name = self._create_persistent_workspace(db, operator, user) + volume_name = self._create_persistent_workspace( + request.db, request.operator, request.user + ) volume = operators_models.PersistentVolume( name="workspace", read_only=False, @@ -53,7 +45,7 @@ def configuration_hook( # type: ignore ) return interface.ConfigurationHookResult( - volumes=[volume], + volumes=[volume], init_volumes=[volume] ) def _check_that_persistent_workspace_is_allowed( diff --git a/backend/capellacollab/sessions/hooks/project_scope.py b/backend/capellacollab/sessions/hooks/project_scope.py new file mode 100644 index 0000000000..e2cc8ba0b9 --- /dev/null +++ b/backend/capellacollab/sessions/hooks/project_scope.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pathlib +import typing as t + +from capellacollab.projects import models as projects_models +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.projects.toolmodels.modelsources.git import ( + models as git_models, +) +from capellacollab.sessions import models as sessions_models + +from . import interface + + +class ResolvedSessionProvisioning(t.TypedDict): + entry: sessions_models.SessionProvisioningRequest + model: toolmodels_models.DatabaseToolModel + project: projects_models.DatabaseProject + git_model: git_models.DatabaseGitModel + + +class ProjectScopeHook(interface.HookRegistration): + """Makes sure to start the session with the correct workspace.""" + + @classmethod + def configuration_hook( + cls, + request: interface.ConfigurationHookRequest, + ) -> interface.ConfigurationHookResult: + environment = {} + + if ( + request.session_type == sessions_models.SessionType.PERSISTENT + and request.project_scope + ): + environment["WORKSPACE_DIR"] = str( + pathlib.PurePosixPath("/workspace") + / request.project_scope.slug + / ("tool-" + str(request.tool_version.tool_id)) + ) + + return interface.ConfigurationHookResult(environment=environment) diff --git a/backend/capellacollab/sessions/hooks/provisioning.py b/backend/capellacollab/sessions/hooks/provisioning.py index 4d1b92c902..52c604acad 100644 --- a/backend/capellacollab/sessions/hooks/provisioning.py +++ b/backend/capellacollab/sessions/hooks/provisioning.py @@ -6,6 +6,7 @@ from sqlalchemy import orm +from capellacollab.core import models as core_models from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.projects import injectables as projects_injectables from capellacollab.projects import models as projects_models @@ -19,9 +20,19 @@ from capellacollab.projects.toolmodels.modelsources.git import ( models as git_models, ) +from capellacollab.projects.toolmodels.provisioning import ( + crud as provisioning_crud, +) +from capellacollab.projects.toolmodels.provisioning import ( + models as provisioning_models, +) from capellacollab.projects.users import models as projects_users_models from capellacollab.sessions import exceptions as sessions_exceptions from capellacollab.sessions import models as sessions_models +from capellacollab.settings.modelsources.git import core as instances_git_core +from capellacollab.settings.modelsources.git import ( + exceptions as instances_git_exceptions, +) from capellacollab.tools import crud as tools_crud from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -40,41 +51,155 @@ class ProvisionWorkspaceHook(interface.HookRegistration): """Takes care of the provisioning of user workspaces.""" @classmethod - def configuration_hook( # type: ignore + async def async_configuration_hook( cls, - db: orm.Session, - tool: tools_models.DatabaseTool, - tool_version: tools_models.DatabaseVersion, - user: users_models.DatabaseUser, - provisioning: list[sessions_models.SessionProvisioningRequest], - **kwargs, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: - max_number_of_models = tool.config.provisioning.max_number_of_models - if max_number_of_models and len(provisioning) > max_number_of_models: - raise sessions_exceptions.TooManyModelsRequestedToProvisionError( - max_number_of_models + if len(request.provisioning) == 0: + if request.tool.config.provisioning.required: + raise sessions_exceptions.ProvisioningRequiredError() + return interface.ConfigurationHookResult( + environment={"CAPELLACOLLAB_SESSION_PROVISIONING": []} ) - resolved_entries = cls._resolve_provisioning_request(db, provisioning) + cls._verify_max_number_of_models(request) + + resolved_entries = cls._resolve_provisioning_request( + request.db, request.provisioning + ) cls._verify_matching_tool_version_and_model( - db, tool_version, resolved_entries + request.db, request.tool_version, resolved_entries + ) + cls._verify_model_permissions( + request.db, request.user, resolved_entries ) - cls._verify_model_permissions(db, user, resolved_entries) - init_environment = { - "CAPELLACOLLAB_PROVISIONING": cls._get_git_repos_json( - resolved_entries, include_credentials=True + init_environment: dict[str, str] = {} + environment: dict[str, str] = {} + warnings: list[core_models.Message] = [] + if request.session_type == sessions_models.SessionType.PERSISTENT: + await cls._persistent_provisioning( + request, + resolved_entries, + init_environment, + environment, + warnings, ) - } - - environment = { - "CAPELLACOLLAB_SESSION_PROVISIONING": cls._get_git_repos_json( - resolved_entries, include_credentials=False + else: + cls._read_only_provisioning( + request, resolved_entries, init_environment, environment ) - } return interface.ConfigurationHookResult( - init_environment=init_environment, environment=environment + init_environment=init_environment, + environment=environment, + warnings=warnings, + ) + + @classmethod + async def _persistent_provisioning( + cls, + request: interface.ConfigurationHookRequest, + resolved_entries: list[ResolvedSessionProvisioning], + init_environment: dict[str, t.Any], + environment: dict[str, t.Any], + warnings: list[core_models.Message], + ): + """Provisioning for persistent sessions""" + + if not request.project_scope: + raise sessions_exceptions.ProjectScopeRequiredError() + + cls._verify_matching_project_and_model( + request.project_scope, resolved_entries + ) + + init_provisioning: list[dict[str, str | int]] = [] + session_provisioning: list[dict[str, str | int]] = [] + + for resolved_entry in resolved_entries: + existing_provisioning = provisioning_crud.get_model_provisioning( + request.db, resolved_entry["model"], request.user + ) + + entry = resolved_entry["entry"] + git_model = resolved_entry["git_model"] + + if existing_provisioning: + entry.revision = existing_provisioning.commit_hash + else: + provisioning = await cls._create_provisioning_record( + request.db, + resolved_entry, + request.user, + ) + + # Set revision to the actual commit hash + entry.revision = provisioning.commit_hash + + if not entry.deep_clone: + warnings.append( + core_models.Message( + err_code="DEEP_CLONE_REQUIRED", + title="Deep clone required.", + reason=( + "Deep clone is required for persistent provisioning." + " The provisioning will continue with deep clone." + ), + ) + ) + entry.deep_clone = True + + if not existing_provisioning: + init_provisioning.append( + cls._git_model_as_json( + git_model, + entry.revision or git_model.revision, + entry.deep_clone, + request.session_type, + include_credentials=True, + ) + ) + + session_provisioning.append( + cls._git_model_as_json( + git_model, + entry.revision or git_model.revision, + entry.deep_clone, + request.session_type, + include_credentials=False, + ) + ) + + init_environment["CAPELLACOLLAB_PROVISIONING"] = init_provisioning + environment["CAPELLACOLLAB_SESSION_PROVISIONING"] = ( + session_provisioning + ) + + @classmethod + def _read_only_provisioning( + cls, + request: interface.ConfigurationHookRequest, + resolved_entries: list[ResolvedSessionProvisioning], + init_environment: dict[str, t.Any], + environment: dict[str, t.Any], + ): + """Provisioning of read-only sessions""" + + init_environment["CAPELLACOLLAB_PROVISIONING"] = ( + cls._get_git_repos_json( + resolved_entries, + request.session_type, + include_credentials=True, + ) + ) + + environment["CAPELLACOLLAB_SESSION_PROVISIONING"] = ( + cls._get_git_repos_json( + resolved_entries, + request.session_type, + include_credentials=False, + ) ) @classmethod @@ -104,6 +229,21 @@ def _resolve_provisioning_request( ) return resolved_entries + @classmethod + def _verify_max_number_of_models( + cls, request: interface.ConfigurationHookRequest + ): + max_number_of_models = ( + request.tool.config.provisioning.max_number_of_models + ) + if ( + max_number_of_models + and len(request.provisioning) > max_number_of_models + ): + raise sessions_exceptions.TooManyModelsRequestedToProvisionError( + max_number_of_models + ) + @classmethod def _verify_matching_tool_version_and_model( cls, @@ -124,6 +264,19 @@ def _verify_matching_tool_version_and_model( model_name=entry["model"].name, ) + @classmethod + def _verify_matching_project_and_model( + cls, + project: projects_models.DatabaseProject, + resolved_entries: list[ResolvedSessionProvisioning], + ): + for entry in resolved_entries: + if entry["project"] != project: + raise sessions_exceptions.ProjectAndModelMismatchError( + project_slug=project.slug, + model_name=entry["model"].name, + ) + @classmethod def _verify_model_permissions( cls, @@ -143,14 +296,16 @@ def _verify_model_permissions( def _get_git_repos_json( cls, resolved_entries: list[ResolvedSessionProvisioning], + session_type: sessions_models.SessionType, include_credentials: bool = False, - ): + ) -> list[dict[str, str | int]]: """Get the git repos as a JSON-serializable list""" return [ cls._git_model_as_json( entry["git_model"], - entry["entry"].revision, + entry["entry"].revision or entry["git_model"].revision, entry["entry"].deep_clone, + session_type, include_credentials, ) for entry in resolved_entries @@ -162,6 +317,7 @@ def _git_model_as_json( git_model: git_models.DatabaseGitModel, revision: str, deep_clone: bool, + session_type: sessions_models.SessionType, include_credentials: bool, ) -> dict[str, str | int]: """Convert a DatabaseGitModel to a JSON-serializable dictionary.""" @@ -178,6 +334,8 @@ def _git_model_as_json( "path": str( pathlib.PurePosixPath( toolmodel.tool.config.provisioning.directory + if session_type == sessions_models.SessionType.READONLY + else "/workspace" ) / toolmodel.project.slug / toolmodel.slug @@ -187,3 +345,39 @@ def _git_model_as_json( git_dict["username"] = git_model.username git_dict["password"] = git_model.password return git_dict + + @classmethod + async def _determine_commit_hash( + cls, revision: str | None, git_model: git_models.DatabaseGitModel + ) -> tuple[str, str]: + revision = revision or git_model.revision + for hash, rev in await instances_git_core.ls_remote( + url=git_model.path, + username=git_model.username, + password=git_model.password, + ): + rev = rev.removeprefix("refs/heads/").removeprefix("refs/tags/") + if rev == revision: + return revision, hash + + raise instances_git_exceptions.RevisionNotFoundError(revision) + + @classmethod + async def _create_provisioning_record( + cls, + db: orm.Session, + resolved_entry: ResolvedSessionProvisioning, + user: users_models.DatabaseUser, + ) -> provisioning_models.DatabaseModelProvisioning: + rev, commit_hash = await cls._determine_commit_hash( + resolved_entry["entry"].revision, resolved_entry["git_model"] + ) + return provisioning_crud.create_model_provisioning( + db, + provisioning_models.DatabaseModelProvisioning( + user=user, + tool_model=resolved_entry["model"], + revision=rev, + commit_hash=commit_hash, + ), + ) diff --git a/backend/capellacollab/sessions/hooks/pure_variants.py b/backend/capellacollab/sessions/hooks/pure_variants.py index 75d8b8c19e..f0016ee620 100644 --- a/backend/capellacollab/sessions/hooks/pure_variants.py +++ b/backend/capellacollab/sessions/hooks/pure_variants.py @@ -5,8 +5,6 @@ import pathlib import typing as t -from sqlalchemy import orm - from capellacollab.core import models as core_models from capellacollab.projects.toolmodels import models as toolmodels_models from capellacollab.sessions import models as sessions_models @@ -26,20 +24,17 @@ class PureVariantsConfigEnvironment(t.TypedDict): class PureVariantsIntegration(interface.HookRegistration): - def configuration_hook( # type: ignore + def configuration_hook( self, - db: orm.Session, - user: users_models.DatabaseUser, - session_type: sessions_models.SessionType, - **kwargs, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: - if session_type == sessions_models.SessionType.READONLY: + if request.session_type == sessions_models.SessionType.READONLY: # Skip read-only sessions, no pure::variants integration supported. return interface.ConfigurationHookResult() if ( - not self._user_has_project_with_pure_variants_model(user) - and user.role == users_models.Role.USER + not self._user_has_project_with_pure_variants_model(request.user) + and request.user.role == users_models.Role.USER ): warnings = [ core_models.Message( @@ -57,7 +52,9 @@ def configuration_hook( # type: ignore warnings=warnings, ) - pv_license = purevariants_crud.get_pure_variants_configuration(db) + pv_license = purevariants_crud.get_pure_variants_configuration( + request.db + ) if not pv_license or pv_license.license_server_url is None: warnings = [ core_models.Message( diff --git a/backend/capellacollab/sessions/hooks/read_only_workspace.py b/backend/capellacollab/sessions/hooks/read_only_workspace.py index 10109a4812..cbe1b45119 100644 --- a/backend/capellacollab/sessions/hooks/read_only_workspace.py +++ b/backend/capellacollab/sessions/hooks/read_only_workspace.py @@ -12,12 +12,10 @@ class ReadOnlyWorkspaceHook(interface.HookRegistration): """Mounts an empty workspace to the container for read-only sessions.""" - def configuration_hook( # type: ignore - self, - session_type: sessions_models.SessionType, - **kwargs, + def configuration_hook( + self, request: interface.ConfigurationHookRequest ) -> interface.ConfigurationHookResult: - if session_type != sessions_models.SessionType.READONLY: + if request.session_type != sessions_models.SessionType.READONLY: # Configuration for persistent workspace sessions happens in the PersistentWorkspaceHook. return interface.ConfigurationHookResult() diff --git a/backend/capellacollab/sessions/hooks/session_preparation.py b/backend/capellacollab/sessions/hooks/session_preparation.py index 4e27ec7f4f..e81c7f53a7 100644 --- a/backend/capellacollab/sessions/hooks/session_preparation.py +++ b/backend/capellacollab/sessions/hooks/session_preparation.py @@ -2,39 +2,30 @@ # SPDX-License-Identifier: Apache-2.0 import pathlib -import typing as t from capellacollab.sessions import models as sessions_models from capellacollab.sessions.operators import models as operators_models -from capellacollab.tools import models as tools_models from . import interface -class PersistentWorkspacEnvironment(t.TypedDict): - pass - - class GitRepositoryCloningHook(interface.HookRegistration): """Creates a volume that is shared between the actual container and the session preparation. The volume is used to clone Git repositories as preparation for the session. """ - def configuration_hook( # type: ignore + def configuration_hook( self, - session_type: sessions_models.SessionType, - session_id: str, - tool: tools_models.DatabaseTool, - **kwargs, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: - if session_type != sessions_models.SessionType.READONLY: + if request.session_type != sessions_models.SessionType.READONLY: return interface.ConfigurationHookResult() shared_model_volume = operators_models.EmptyVolume( - name=f"{session_id}-models", + name=f"{request.session_id}-models", container_path=pathlib.PurePosixPath( - tool.config.provisioning.directory + request.tool.config.provisioning.directory ), read_only=False, ) diff --git a/backend/capellacollab/sessions/hooks/t4c.py b/backend/capellacollab/sessions/hooks/t4c.py index e6ffd4a884..92f03ba81e 100644 --- a/backend/capellacollab/sessions/hooks/t4c.py +++ b/backend/capellacollab/sessions/hooks/t4c.py @@ -17,7 +17,6 @@ from capellacollab.settings.modelsources.t4c.instance.repositories import ( interface as repo_interface, ) -from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models from .. import models as sessions_models @@ -34,22 +33,19 @@ class T4CConfigEnvironment(t.TypedDict): class T4CIntegration(interface.HookRegistration): - def configuration_hook( # type: ignore - self, - db: orm.Session, - user: users_models.DatabaseUser, - tool_version: tools_models.DatabaseVersion, - session_type: sessions_models.SessionType, - **kwargs, + def configuration_hook( + self, request: interface.ConfigurationHookRequest ) -> interface.ConfigurationHookResult: - if session_type != sessions_models.SessionType.PERSISTENT: + user = request.user + + if request.session_type != sessions_models.SessionType.PERSISTENT: # Skip non-persistent sessions, no T4C integration needed. return interface.ConfigurationHookResult() warnings: list[core_models.Message] = [] t4c_repositories = repo_crud.get_user_t4c_repositories( - db, tool_version, user + request.db, request.tool_version, user ) t4c_json = json.dumps( @@ -91,7 +87,7 @@ def configuration_hook( # type: ignore password=environment["T4C_PASSWORD"], is_admin=auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(user.name, db), + )(user.name, request.db), ) except requests.RequestException: warnings.append( @@ -116,30 +112,25 @@ def configuration_hook( # type: ignore environment=environment, warnings=warnings ) - def pre_session_termination_hook( # type: ignore - self, - db: orm.Session, - session: sessions_models.DatabaseSession, - **kwargs, + def pre_session_termination_hook( + self, request: interface.PreSessionTerminationHookRequest ): - if session.type == sessions_models.SessionType.PERSISTENT: - self._revoke_session_tokens(db, session) + if request.session.type == sessions_models.SessionType.PERSISTENT: + self._revoke_session_tokens(request.db, request.session) - def session_connection_hook( # type: ignore[override] + def session_connection_hook( self, - db_session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - **kwargs, + request: interface.SessionConnectionHookRequest, ) -> interface.SessionConnectionHookResult: - if db_session.type != sessions_models.SessionType.PERSISTENT: + if request.db_session.type != sessions_models.SessionType.PERSISTENT: return interface.SessionConnectionHookResult() - if db_session.owner != user: + if request.db_session.owner != request.user: # The session is shared, don't provide the T4C token. return interface.SessionConnectionHookResult() return interface.SessionConnectionHookResult( - t4c_token=db_session.environment.get("T4C_PASSWORD") + t4c_token=request.db_session.environment.get("T4C_PASSWORD") ) def _revoke_session_tokens( diff --git a/backend/capellacollab/sessions/idletimeout.py b/backend/capellacollab/sessions/idletimeout.py index e377c02719..d3fd0f6a1c 100644 --- a/backend/capellacollab/sessions/idletimeout.py +++ b/backend/capellacollab/sessions/idletimeout.py @@ -18,6 +18,7 @@ def terminate_idle_session(): + log.debug("Starting to terminate idle sessions...") url = config.prometheus.url url += "/".join(("api", "v1", 'query?query=ALERTS{alertstate="firing"}')) response = requests.get( @@ -42,6 +43,7 @@ def terminate_idle_session(): session_id, ) operators.get_operator().kill_session(session_id) + log.debug("Finished termination of idle sessions.") def terminate_idle_sessions_in_background(interval=60): diff --git a/backend/capellacollab/sessions/injection.py b/backend/capellacollab/sessions/injection.py index 87741d4212..8fde8bd9bf 100644 --- a/backend/capellacollab/sessions/injection.py +++ b/backend/capellacollab/sessions/injection.py @@ -16,6 +16,8 @@ def get_last_seen(sid: str) -> str: if core.LOCAL_DEVELOPMENT_MODE: return "Disabled in development mode" + log.debug("Starting to get last seen for session %s.", sid) + url = f"{config.prometheus.url}/api/v1/query?query=idletime_minutes" try: response = requests.get( @@ -26,7 +28,11 @@ def get_last_seen(sid: str) -> str: for session in response.json()["data"]["result"]: if sid == session["metric"]["session_id"]: - return _get_last_seen(float(session["value"][1])) + last_seen = _get_last_seen(float(session["value"][1])) + log.debug( + "Returning last seen %s for session %s.", last_seen, sid + ) + return last_seen log.debug("Couldn't find Prometheus metrics for session %s.", sid) except Exception: diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index 3330a3c8f4..890ddfc296 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -22,6 +22,9 @@ from . import injection if t.TYPE_CHECKING: + from capellacollab.projects.toolmodels.provisioning.models import ( + DatabaseModelProvisioning, + ) from capellacollab.tools.models import DatabaseTool, DatabaseVersion from capellacollab.users.models import DatabaseUser @@ -50,7 +53,7 @@ class SessionProvisioningRequest(core_pydantic.BaseModel): project_slug: str toolmodel_slug: str = pydantic.Field(alias="model_slug") git_model_id: int - revision: str + revision: str | None = None deep_clone: bool @@ -59,10 +62,22 @@ class PostSessionRequest(core_pydantic.BaseModel): version_id: int session_type: SessionType = pydantic.Field(default=SessionType.PERSISTENT) - connection_method_id: str = pydantic.Field( - description="The identifier of the connection method to use" + connection_method_id: str | None = pydantic.Field( + default=None, + description=( + "The identifier of the connection method to use." + " If None, the default connection method will be used." + ), ) provisioning: list[SessionProvisioningRequest] = pydantic.Field(default=[]) + project_slug: str | None = pydantic.Field( + default=None, + description=( + "The project to run the session in." + " Required for persistent provisioned sessions." + " Ignored for readonly sessions." + ), + ) class SessionSharing(core_pydantic.BaseModel): @@ -182,6 +197,18 @@ class DatabaseSession(database.Base): connection_method_id: orm.Mapped[str] + provisioning_id: orm.Mapped[int | None] = orm.mapped_column( + sa.ForeignKey("model_provisioning.id"), + init=False, + ) + provisioning: orm.Mapped[DatabaseModelProvisioning | None] = ( + orm.relationship( + back_populates="session", + foreign_keys=[provisioning_id], + default=None, + ) + ) + environment: orm.Mapped[dict[str, str]] = orm.mapped_column( nullable=False, default_factory=dict ) diff --git a/backend/capellacollab/sessions/operators/k8s.py b/backend/capellacollab/sessions/operators/k8s.py index 3d877c9cb6..a4d1678ec4 100644 --- a/backend/capellacollab/sessions/operators/k8s.py +++ b/backend/capellacollab/sessions/operators/k8s.py @@ -19,6 +19,7 @@ import kubernetes.config import kubernetes.stream.stream import prometheus_client +import typing_extensions as te # codespell:ignore te import yaml from kubernetes import client from kubernetes.client import exceptions @@ -53,7 +54,7 @@ ) -class Session(t.TypedDict): +class Session(te.TypedDict): # codespell:ignore te id: str port: int created_at: datetime.datetime diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 545b56f4cb..c4bed0b02e 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -17,8 +17,11 @@ from capellacollab.core import responses from capellacollab.core.authentication import exceptions as auth_exceptions from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.sessions import hooks +from capellacollab.projects import injectables as projects_injectables +from capellacollab.projects.users import models as projects_users_models +from capellacollab.sessions import hooks as sessions_hooks from capellacollab.sessions.files import routes as files_routes +from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.tools import exceptions as tools_exceptions from capellacollab.tools import injectables as tools_injectables from capellacollab.tools import models as tools_models @@ -82,7 +85,7 @@ ], ), ) -def request_session( +async def request_session( body: models.PostSessionRequest, user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user @@ -95,27 +98,31 @@ def request_session( "Starting %s session for user %s", body.session_type, user.name ) - # Provisioning will be supported in the future: - # https://github.com/DSD-DBS/capella-collab-manager/issues/1004 - if ( - body.session_type == models.SessionType.PERSISTENT - and body.provisioning - ): - raise exceptions.ProvisioningUnsupportedError() - tool = tools_injectables.get_existing_tool(body.tool_id, db) version = tools_injectables.get_existing_tool_version( tool.id, body.version_id, db ) - connection_method: tools_models.ToolSessionConnectionMethod = ( - util.get_connection_method(tool, body.connection_method_id) - ) + if body.connection_method_id: + connection_method: tools_models.ToolSessionConnectionMethod = ( + util.get_connection_method(tool, body.connection_method_id) + ) + else: + connection_method = tool.config.connection.methods[0] session_id = util.generate_id() util.raise_if_conflicting_sessions(tool, version, body.session_type, user) + project_scope = None + if body.project_slug: + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.USER + )(body.project_slug, user.name, db) + project_scope = projects_injectables.get_existing_project( + body.project_slug, db + ) + environment = t.cast( dict[str, str], util.get_environment(user, connection_method, session_id), @@ -125,19 +132,22 @@ def request_session( init_volumes: list[operators_models.Volume] = [] init_environment: dict[str, str] = {} - for hook in hooks.get_activated_integration_hooks(tool): - hook_result = hook.configuration_hook( - db=db, - user=user, - tool_version=version, - tool=tool, - username=user.name, - operator=operator, - session_type=body.session_type, - connection_method=connection_method, - provisioning=body.provisioning, - session_id=session_id, - ) + hook_request = hooks_interface.ConfigurationHookRequest( + db=db, + user=user, + tool_version=version, + tool=tool, + operator=operator, + session_type=body.session_type, + connection_method=connection_method, + provisioning=body.provisioning, + session_id=session_id, + project_scope=project_scope, + ) + + for hook_result in await util.schedule_configuration_hooks( + hook_request, tool + ): environment |= hook_result.get("environment", {}) init_environment |= hook_result.get("init_environment", {}) volumes += hook_result.get("volumes", []) @@ -224,14 +234,16 @@ def request_session( ) hook_config: dict[str, str] = {} - for hook in hooks.get_activated_integration_hooks(tool): + for hook in sessions_hooks.get_activated_integration_hooks(tool): result = hook.post_session_creation_hook( - session_id=session_id, - operator=operator, - user=user, - session=session, - db_session=db_session, - connection_method=connection_method, + hooks_interface.PostSessionCreationHookRequest( + session_id=session_id, + operator=operator, + user=user, + session=session, + db_session=db_session, + connection_method=connection_method, + ) ) hook_config |= result.get("config", {}) @@ -367,13 +379,15 @@ def get_session_connection_information( redirect_url = None t4c_token = None - for hook in hooks.get_activated_integration_hooks(session.tool): + for hook in sessions_hooks.get_activated_integration_hooks(session.tool): hook_result = hook.session_connection_hook( - db=db, - user=user, - db_session=session, - connection_method=connection_method, - logger=logger, + hooks_interface.SessionConnectionHookRequest( + db=db, + db_session=session, + connection_method=connection_method, + logger=logger, + user=user, + ) ) local_storage |= hook_result.get("local_storage", {}) diff --git a/backend/capellacollab/sessions/util.py b/backend/capellacollab/sessions/util.py index 60c8a69855..6d0b026644 100644 --- a/backend/capellacollab/sessions/util.py +++ b/backend/capellacollab/sessions/util.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import asyncio import json import logging import random @@ -12,7 +13,7 @@ from capellacollab.config import config from capellacollab.core import credentials from capellacollab.core import models as core_models -from capellacollab.sessions import hooks +from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.operators import k8s from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -33,11 +34,12 @@ def terminate_session( ) for hook in hooks.get_activated_integration_hooks(session.tool): hook.pre_session_termination_hook( - db=db, - session=session, - operator=operator, - user=session.owner, - connection_method=connection_method, + hooks_interface.PreSessionTerminationHookRequest( + db=db, + session=session, + operator=operator, + connection_method=connection_method, + ) ) crud.delete_session(db, session) @@ -205,3 +207,24 @@ def is_session_shared_with_user( session: models.DatabaseSession, user: users_models.DatabaseUser ) -> bool: return user in [shared.user for shared in session.shared_with] + + +async def schedule_configuration_hooks( + request: hooks_interface.ConfigurationHookRequest, + tool: tools_models.DatabaseTool, +) -> list[hooks_interface.ConfigurationHookResult]: + """Schedule sync and async configuration hooks + + Schedule async hooks, then schedule the sync hooks + and finally collect the async hook results + """ + + activated_hooks = hooks.get_activated_integration_hooks(tool) + + async_hooks = [ + hook.async_configuration_hook(request) for hook in activated_hooks + ] + + return [ + hook.configuration_hook(request) for hook in activated_hooks + ] + await asyncio.gather(*async_hooks) diff --git a/backend/capellacollab/settings/modelsources/git/core.py b/backend/capellacollab/settings/modelsources/git/core.py index ea23924ffa..6863059cb8 100644 --- a/backend/capellacollab/settings/modelsources/git/core.py +++ b/backend/capellacollab/settings/modelsources/git/core.py @@ -2,18 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import collections.abc as cabc import logging import os import pathlib import subprocess +import typing as t from . import exceptions, models log = logging.getLogger(__name__) -async def ls_remote(url: str, env: cabc.Mapping[str, str]) -> list[str]: +async def _ls_remote_command(url: str, env: t.Mapping[str, str]) -> str: + """Runs ls-remote on a repository and returns stdout""" try: proc = await asyncio.create_subprocess_exec( "git", @@ -36,8 +37,30 @@ async def ls_remote(url: str, env: cabc.Mapping[str, str]) -> list[str]: raise exceptions.GitRepositoryAccessError() else: raise e + stdout, _ = await proc.communicate() - return stdout.decode().strip().splitlines() + return stdout.decode() + + +async def ls_remote( + url: str, username: str | None, password: str | None +) -> list[tuple[str, str]]: + env = { + "GIT_USERNAME": username or "", + "GIT_PASSWORD": password or "", + "GIT_ASKPASS": str( + (pathlib.Path(__file__).parents[0] / "askpass.py").absolute() + ), + } + + stdout = await _ls_remote_command(url, env) + + resolved_revisions = [] + for line in stdout.strip().splitlines(): + (hash, rev) = line.split("\t") + resolved_revisions.append((hash, rev)) + + return resolved_revisions async def get_remote_refs( @@ -47,15 +70,7 @@ async def get_remote_refs( models.GetRevisionsResponseModel(branches=[], tags=[]) ) - git_env = { - "GIT_USERNAME": username or "", - "GIT_PASSWORD": password or "", - "GIT_ASKPASS": str( - (pathlib.Path(__file__).parents[0] / "askpass.py").absolute() - ), - } - for ref in await ls_remote(url, git_env): - (_, ref) = ref.split("\t") + for _, ref in await ls_remote(url, username, password): if "^" in ref: continue if ref.startswith("refs/heads/"): diff --git a/backend/capellacollab/settings/modelsources/git/exceptions.py b/backend/capellacollab/settings/modelsources/git/exceptions.py index dcf656a6aa..4d8df820e7 100644 --- a/backend/capellacollab/settings/modelsources/git/exceptions.py +++ b/backend/capellacollab/settings/modelsources/git/exceptions.py @@ -41,3 +41,13 @@ def __init__(self): ), err_code="NO_GIT_INSTANCE_WITH_PREFIX_FOUND", ) + + +class RevisionNotFoundError(core_exceptions.BaseError): + def __init__(self, revision: str): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + title="Revision not found in repository", + reason=f"The revision '{revision}' is not a valid branch or tag name.", + err_code="GIT_REVISION_NOT_FOUND", + ) diff --git a/backend/capellacollab/settings/modelsources/git/routes.py b/backend/capellacollab/settings/modelsources/git/routes.py index 2d6bc53cc6..9c11b9fea0 100644 --- a/backend/capellacollab/settings/modelsources/git/routes.py +++ b/backend/capellacollab/settings/modelsources/git/routes.py @@ -8,7 +8,7 @@ from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.settings.modelsources.git import core as git_core +from capellacollab.settings.modelsources.git import core as instances_git_core from capellacollab.users import models as users_models from . import crud, injectables, models, util @@ -111,7 +111,7 @@ async def get_revisions( username = body.credentials.username password = body.credentials.password - return await git_core.get_remote_refs(url, username, password) + return await instances_git_core.get_remote_refs(url, username, password) @router.post("/validate/path", response_model=bool) diff --git a/backend/capellacollab/tools/models.py b/backend/capellacollab/tools/models.py index 46e506b79c..d1cc8e18ed 100644 --- a/backend/capellacollab/tools/models.py +++ b/backend/capellacollab/tools/models.py @@ -270,6 +270,13 @@ class ToolModelProvisioning(core_pydantic.BaseModel): ), examples=[None, 1], ) + required: bool = pydantic.Field( + default=False, + description=( + "Specifies if a tool requires provisioning." + " If enabled and a session without provisioning is requested, it will be declined." + ), + ) class PersistentWorkspaceSessionConfiguration(core_pydantic.BaseModel): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index dcf2abc8f5..83d8802a7f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -72,6 +72,7 @@ dev = [ "types-lxml", "types-croniter", "pyinstrument", + "pytest-asyncio", ] [tool.black] diff --git a/backend/tests/core/test_auth_injectables.py b/backend/tests/core/test_auth_injectables.py index e8d22fa567..940db88848 100644 --- a/backend/tests/core/test_auth_injectables.py +++ b/backend/tests/core/test_auth_injectables.py @@ -18,13 +18,6 @@ def fixture_verify(request: pytest.FixtureRequest) -> bool: return request.param -@pytest.fixture(name="user2") -def fixture_user2(db: orm.Session) -> users_models.DatabaseUser: - return users_crud.create_user( - db, "user2", "user2", None, users_models.Role.USER - ) - - @pytest.fixture(name="admin2") def fixture_admin2(db: orm.Session) -> users_models.DatabaseUser: return users_crud.create_user( diff --git a/backend/tests/projects/test_projects_tools.py b/backend/tests/projects/test_projects_tools.py new file mode 100644 index 0000000000..c206426ed4 --- /dev/null +++ b/backend/tests/projects/test_projects_tools.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.projects import models as projects_models +from capellacollab.projects.tools import crud as projects_tools_crud +from capellacollab.projects.tools import models as projects_tools_models +from capellacollab.tools import models as tools_models + + +@pytest.fixture(name="project_tool") +def fixture_jupyter_project_tool( + db: orm.Session, + project: projects_models.DatabaseProject, + tool_version: tools_models.DatabaseVersion, +) -> projects_tools_models.DatabaseProjectToolAssociation: + return projects_tools_crud.create_project_tool(db, project, tool_version) + + +@pytest.mark.usefixtures("capella_model", "project_tool", "project_user") +def test_get_project_tools( + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_tool_version: tools_models.DatabaseVersion, + tool_version: tools_models.DatabaseVersion, +): + """Test to get all tools of a project + + Explicitly test that manually added tools + and auto-added tools are listed. + """ + + response = client.get(f"/api/v1/projects/{project.slug}/tools") + + assert response.status_code == 200 + json = response.json() + + assert len(json) == 2 + assert json[0]["tool_version"]["id"] == tool_version.id + assert json[1]["tool_version"]["id"] == capella_tool_version.id + assert len(json[1]["used_by"]) == 1 + + +@pytest.mark.usefixtures("project_manager") +def test_link_tool_to_project( + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_tool_version: tools_models.DatabaseVersion, +): + """Test to link a tool to a project""" + + response = client.post( + f"/api/v1/projects/{project.slug}/tools", + json={ + "tool_version_id": capella_tool_version.id, + "tool_id": capella_tool_version.tool.id, + }, + ) + + assert response.status_code == 200 + assert response.json()["tool_version"]["id"] == capella_tool_version.id + + +@pytest.mark.usefixtures("project_tool", "project_manager") +def test_link_tool_to_project_already_linked( + client: testclient.TestClient, + project: projects_models.DatabaseProject, + tool_version: tools_models.DatabaseVersion, +): + """Test to link a tool to a project that is already linked""" + response = client.post( + f"/api/v1/projects/{project.slug}/tools", + json={ + "tool_version_id": tool_version.id, + "tool_id": tool_version.tool.id, + }, + ) + + assert response.status_code == 409 + assert ( + response.json()["detail"]["err_code"] + == "TOOL_ALREADY_EXISTS_IN_PROJECT" + ) + + +@pytest.mark.usefixtures("project_manager") +def test_remove_tool_from_project( + db: orm.Session, + client: testclient.TestClient, + project: projects_models.DatabaseProject, + project_tool: projects_tools_models.DatabaseProjectToolAssociation, +): + """Test to remove a tool to a project""" + + response = client.delete( + f"/api/v1/projects/{project.slug}/tools/{project_tool.id}" + ) + + assert response.status_code == 204 + assert ( + projects_tools_crud.get_project_tool_by_id(db, project_tool.id) is None + ) + + +@pytest.mark.usefixtures("project_manager") +def test_remove_non_existing_tool_from_project( + db: orm.Session, + client: testclient.TestClient, + project: projects_models.DatabaseProject, +): + """Test to remove a non-existing tools to a project""" + + response = client.delete(f"/api/v1/projects/{project.slug}/tools/0") + + assert response.status_code == 404 + assert response.json()["detail"]["err_code"] == "PROJECT_TOOL_NOT_FOUND" diff --git a/backend/tests/projects/test_projects_users_routes.py b/backend/tests/projects/test_projects_users_routes.py index 5ddfe775b3..04979e7546 100644 --- a/backend/tests/projects/test_projects_users_routes.py +++ b/backend/tests/projects/test_projects_users_routes.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import pytest from fastapi import testclient from sqlalchemy import orm @@ -12,32 +13,25 @@ from capellacollab.users import models as users_models +@pytest.mark.usefixtures("admin") def test_assign_read_write_permission_when_adding_manager( db: orm.Session, client: testclient.TestClient, - executor_name: str, - unique_username: str, + user2: users_models.DatabaseUser, project: projects_models.DatabaseProject, ): - users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - response = client.post( f"/api/v1/projects/{project.slug}/users/", json={ "role": projects_users_models.ProjectUserRole.MANAGER.value, "permission": projects_users_models.ProjectUserPermission.READ.value, - "username": user.name, + "username": user2.name, "reason": "", }, ) project_user = projects_users_crud.get_project_user_association( - db, project, user + db, project, user2 ) assert response.status_code == 200 @@ -49,30 +43,23 @@ def test_assign_read_write_permission_when_adding_manager( ) +@pytest.mark.usefixtures("admin") def test_assign_read_write_permission_when_changing_project_role_to_manager( db: orm.Session, client: testclient.TestClient, - executor_name: str, - unique_username: str, project: projects_models.DatabaseProject, + user2: users_models.DatabaseUser, ): - users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - projects_users_crud.add_user_to_project( db, project, - user, + user2, projects_users_models.ProjectUserRole.USER, projects_users_models.ProjectUserPermission.READ, ) response = client.patch( - f"/api/v1/projects/{project.slug}/users/{user.id}", + f"/api/v1/projects/{project.slug}/users/{user2.id}", json={ "role": projects_users_models.ProjectUserRole.MANAGER.value, "reason": "", @@ -80,7 +67,7 @@ def test_assign_read_write_permission_when_changing_project_role_to_manager( ) project_user = projects_users_crud.get_project_user_association( - db, project, user + db, project, user2 ) assert response.status_code == 204 @@ -92,30 +79,23 @@ def test_assign_read_write_permission_when_changing_project_role_to_manager( ) +@pytest.mark.usefixtures("admin") def test_http_exception_when_updating_permission_of_manager( db: orm.Session, client: testclient.TestClient, - executor_name: str, - unique_username: str, + user2: users_models.DatabaseUser, project: projects_models.DatabaseProject, ): - users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - projects_users_crud.add_user_to_project( db, project, - user, + user2, projects_users_models.ProjectUserRole.MANAGER, projects_users_models.ProjectUserPermission.WRITE, ) response = client.patch( - f"/api/v1/projects/{project.slug}/users/{user.id}", + f"/api/v1/projects/{project.slug}/users/{user2.id}", json={ "permission": projects_users_models.ProjectUserPermission.READ.value, "reason": "", diff --git a/backend/tests/projects/toolmodels/provisioning/fixtures.py b/backend/tests/projects/toolmodels/provisioning/fixtures.py new file mode 100644 index 0000000000..f31a2d3cb8 --- /dev/null +++ b/backend/tests/projects/toolmodels/provisioning/fixtures.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime + +import pytest +from sqlalchemy import orm + +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.projects.toolmodels.provisioning import ( + crud as provisioning_crud, +) +from capellacollab.projects.toolmodels.provisioning import ( + models as provisioning_models, +) +from capellacollab.users import models as users_models + + +@pytest.fixture(name="provisioning") +def fixture_provisioning( + db: orm.Session, + user: users_models.DatabaseUser, + capella_model: toolmodels_models.DatabaseToolModel, +): + return provisioning_crud.create_model_provisioning( + db, + provisioning_models.DatabaseModelProvisioning( + user=user, + tool_model=capella_model, + revision="main", + commit_hash="db45166576e7f1e7fec3256e8657ba431f9b5b77", + provisioned_at=datetime.datetime.now(), + session=None, + ), + ) diff --git a/backend/tests/projects/toolmodels/provisioning/test_provisioning.py b/backend/tests/projects/toolmodels/provisioning/test_provisioning.py new file mode 100644 index 0000000000..4c90a50cc7 --- /dev/null +++ b/backend/tests/projects/toolmodels/provisioning/test_provisioning.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.projects import models as projects_models +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.projects.toolmodels.provisioning import ( + crud as provisioning_crud, +) +from capellacollab.projects.toolmodels.provisioning import ( + models as provisioning_models, +) +from capellacollab.users import models as users_models + + +@pytest.mark.usefixtures("project_user") +def test_get_non_existing_provisioning( + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, +): + """Test that a non-existing provisioning returns None""" + + response = client.get( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}/provisioning" + ) + + assert response.status_code == 200 + assert response.json() is None + + +@pytest.mark.usefixtures("project_user") +def test_get_provisioning( + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, + provisioning: provisioning_models.DatabaseModelProvisioning, +): + """Test to get an existing provisioning""" + response = client.get( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}/provisioning" + ) + + assert response.status_code == 200 + + assert response.json() is not None + assert response.json()["commit_hash"] == provisioning.commit_hash + + +@pytest.mark.usefixtures("project_user", "provisioning") +def test_delete_provisioning( + db: orm.Session, + user: users_models.DatabaseUser, + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, +): + """Test to delete an existing provisioning""" + response = client.delete( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}/provisioning" + ) + + assert response.status_code == 204 + assert ( + provisioning_crud.get_model_provisioning(db, capella_model, user) + is None + ) + + +@pytest.mark.usefixtures("project_user") +def test_delete_non_existing_provisioning( + db: orm.Session, + user: users_models.DatabaseUser, + client: testclient.TestClient, + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, +): + """Test to delete an non-existing provisioning""" + + response = client.delete( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}/provisioning" + ) + + assert response.status_code == 404 + assert response.json()["detail"]["err_code"] == "PROVISIONING_NOT_FOUND" diff --git a/backend/tests/sessions/hooks/conftest.py b/backend/tests/sessions/hooks/conftest.py new file mode 100644 index 0000000000..1dfb229165 --- /dev/null +++ b/backend/tests/sessions/hooks/conftest.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime +import logging + +import pytest +from sqlalchemy import orm + +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.hooks import interface as hooks_interface +from capellacollab.sessions.operators import k8s as k8s_operator +from capellacollab.tools import models as tools_models +from capellacollab.users import models as users_models + + +@pytest.fixture(name="configuration_hook_request") +def fixture_configuration_hook_request( + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool: tools_models.DatabaseTool, + capella_tool_version: tools_models.DatabaseVersion, +) -> hooks_interface.ConfigurationHookRequest: + return hooks_interface.ConfigurationHookRequest( + db=db, + operator=k8s_operator.KubernetesOperator(), + user=user, + tool=capella_tool, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + connection_method=tools_models.GuacamoleConnectionMethod(), + provisioning=[], + session_id="nxylxqbmfqwvswlqlcbsirvrt", + project_scope=None, + ) + + +@pytest.fixture(name="post_session_creation_hook_request") +def fixture_post_session_creation_hook_request( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, +) -> hooks_interface.PostSessionCreationHookRequest: + return hooks_interface.PostSessionCreationHookRequest( + session_id="test", + db_session=session, + session={ + "id": "test", + "port": 8080, + "created_at": datetime.datetime.fromisoformat( + "2021-01-01T00:00:00" + ), + "host": "test", + }, + user=user, + connection_method=tools_models.GuacamoleConnectionMethod(), + operator=k8s_operator.KubernetesOperator(), + ) + + +@pytest.fixture(name="session_connection_hook_request") +def fixture_session_connection_hook_request( + db: orm.Session, + user: users_models.DatabaseUser, + session: sessions_models.DatabaseSession, + logger: logging.LoggerAdapter, +) -> hooks_interface.SessionConnectionHookRequest: + + return hooks_interface.SessionConnectionHookRequest( + db=db, + db_session=session, + connection_method=tools_models.GuacamoleConnectionMethod(), + logger=logger, + user=user, + ) + + +@pytest.fixture(name="pre_session_termination_hook_request") +def fixture_pre_session_termination_hook_request( + db: orm.Session, + session: sessions_models.DatabaseSession, +) -> hooks_interface.PreSessionTerminationHookRequest: + return hooks_interface.PreSessionTerminationHookRequest( + db=db, + connection_method=tools_models.GuacamoleConnectionMethod(), + operator=k8s_operator.KubernetesOperator(), + session=session, + ) diff --git a/backend/tests/sessions/hooks/test_guacamole_hook.py b/backend/tests/sessions/hooks/test_guacamole_hook.py index 3a6013cfee..c79dece563 100644 --- a/backend/tests/sessions/hooks/test_guacamole_hook.py +++ b/backend/tests/sessions/hooks/test_guacamole_hook.py @@ -127,23 +127,12 @@ def match_user_creation_body( "guacamole_create_token", "guacamole_delete_user", "guacamole_apis" ) def test_guacamole_configuration_hook( - session: sessions_models.DatabaseSession, + post_session_creation_hook_request: session_hooks_interface.PostSessionCreationHookRequest, ): """Test that the Guacamole hook creates a user and a connection""" response = guacamole.GuacamoleIntegration().post_session_creation_hook( - session_id="test", - db_session=session, - session={ - "id": "test", - "port": "8080", - "created_at": "2021-01-01T00:00:00", - "host": "test", - }, - user=users_models.DatabaseUser( - name="test", idp_identifier="test", role=users_models.Role.USER - ), - connection_method=tools_models.GuacamoleConnectionMethod(), + post_session_creation_hook_request ) assert response["config"]["guacamole_username"] @@ -157,7 +146,7 @@ def test_guacamole_configuration_hook( "guacamole_create_token", "guacamole_delete_user", "guacamole_apis" ) def test_fail_if_guacamole_unreachable( - session: sessions_models.DatabaseSession, + post_session_creation_hook_request: session_hooks_interface.PostSessionCreationHookRequest, ): """If Guacamole is unreachable, the session hook will abort the session creation""" @@ -171,18 +160,7 @@ def test_fail_if_guacamole_unreachable( with pytest.raises(guacamole.GuacamoleError): guacamole.GuacamoleIntegration().post_session_creation_hook( - session_id="test", - db_session=session, - session={ - "id": "test", - "port": "8080", - "created_at": "2021-01-01T00:00:00", - "host": "test", - }, - user=users_models.DatabaseUser( - name="test", idp_identifier="test", role=users_models.Role.USER - ), - connection_method=tools_models.GuacamoleConnectionMethod(), + post_session_creation_hook_request ) @@ -191,26 +169,18 @@ def test_fail_if_guacamole_unreachable( "guacamole_create_token", "guacamole_delete_user", "guacamole_apis" ) def test_guacamole_hook_not_executed_for_http_method( - session: sessions_models.DatabaseSession, + post_session_creation_hook_request: session_hooks_interface.PostSessionCreationHookRequest, ): """Skip if connection method is not Guacamole If the connection method is not Guacamole, the hook should skip the preparation. """ + post_session_creation_hook_request.connection_method = ( + tools_models.HTTPConnectionMethod() + ) response = guacamole.GuacamoleIntegration().post_session_creation_hook( - session_id="test", - session={ - "id": "test", - "port": "8080", - "created_at": "2021-01-01T00:00:00", - "host": "test", - }, - db_session=session, - user=users_models.DatabaseUser( - name="test", idp_identifier="test", role=users_models.Role.USER - ), - connection_method=tools_models.HTTPConnectionMethod(), + post_session_creation_hook_request ) assert session_hooks_interface.PostSessionCreationHookResult() == response @@ -222,23 +192,12 @@ def test_guacamole_hook_not_executed_for_http_method( "guacamole_create_token", "guacamole_delete_user", "guacamole_apis" ) def test_skip_guacamole_user_deletion_on_404( - session: sessions_models.DatabaseSession, + post_session_creation_hook_request: session_hooks_interface.PostSessionCreationHookRequest, ): """If the user does not exist, the hook should not fail""" response = guacamole.GuacamoleIntegration().post_session_creation_hook( - session_id="test", - db_session=session, - session={ - "id": "test", - "port": "8080", - "created_at": "2021-01-01T00:00:00", - "host": "test", - }, - user=users_models.DatabaseUser( - name="test", idp_identifier="test", role=users_models.Role.USER - ), - connection_method=tools_models.GuacamoleConnectionMethod(), + post_session_creation_hook_request ) assert response["config"] diff --git a/backend/tests/sessions/hooks/test_http_hook.py b/backend/tests/sessions/hooks/test_http_hook.py index 79acf10ed3..1fc65cc55a 100644 --- a/backend/tests/sessions/hooks/test_http_hook.py +++ b/backend/tests/sessions/hooks/test_http_hook.py @@ -1,19 +1,15 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -import logging - from capellacollab.sessions import models as sessions_models from capellacollab.sessions.hooks import http from capellacollab.sessions.hooks import interface as sessions_hooks_interface from capellacollab.tools import models as tools_models -from capellacollab.users import models as users_models def test_http_hook( session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - logger: logging.LoggerAdapter, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): session.environment = { "TEST": "test", @@ -23,11 +19,10 @@ def test_http_hook( redirect_url="http://localhost:8000/{TEST}", cookies={"test": "{TEST}"}, ) + session_connection_hook_request.connection_method = connection_method + session_connection_hook_request.db_session = session result = http.HTTPIntegration().session_connection_hook( - db_session=session, - user=user, - connection_method=connection_method, - logger=logger, + session_connection_hook_request ) assert result["cookies"]["test"] == "test" @@ -36,33 +31,26 @@ def test_http_hook( def test_skip_http_hook_if_guacamole( - session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - logger: logging.LoggerAdapter, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): result = http.HTTPIntegration().session_connection_hook( - db_session=session, - connection_method=tools_models.GuacamoleConnectionMethod(), - user=user, - logger=logger, + session_connection_hook_request ) assert result == sessions_hooks_interface.SessionConnectionHookResult() def test_fail_derive_redirect_url( session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - logger: logging.LoggerAdapter, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): session.environment = {"TEST": "test"} connection_method = tools_models.HTTPConnectionMethod( redirect_url="http://localhost:8000/{TEST2}" ) + session_connection_hook_request.connection_method = connection_method + session_connection_hook_request.db_session = session result = http.HTTPIntegration().session_connection_hook( - db_session=session, - connection_method=connection_method, - user=user, - logger=logger, + session_connection_hook_request ) assert len(result["warnings"]) == 1 diff --git a/backend/tests/sessions/hooks/test_jupyter_hook.py b/backend/tests/sessions/hooks/test_jupyter_hook.py index a4333561bb..8c9a36817b 100644 --- a/backend/tests/sessions/hooks/test_jupyter_hook.py +++ b/backend/tests/sessions/hooks/test_jupyter_hook.py @@ -3,34 +3,40 @@ import pytest -from sqlalchemy import orm import capellacollab.projects.toolmodels.models as toolmodels_models +from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.hooks import jupyter as jupyter_hook +from capellacollab.sessions.operators import models as operators_models from capellacollab.tools import models as tools_models -from capellacollab.users import models as users_models @pytest.mark.usefixtures("project_user") def test_jupyter_successful_volume_mount( jupyter_model: toolmodels_models.DatabaseToolModel, jupyter_tool: tools_models.DatabaseTool, - user: users_models.DatabaseUser, - db: orm.Session, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): + class MockOperator: # pylint: disable=unused-argument def persistent_volume_exists(self, name: str) -> bool: return True + configuration_hook_request.operator = MockOperator() # type: ignore + configuration_hook_request.tool = jupyter_tool + result = jupyter_hook.JupyterIntegration().configuration_hook( - db=db, user=user, tool=jupyter_tool, operator=MockOperator() + configuration_hook_request ) assert not result["warnings"] assert len(result["volumes"]) == 1 + + volume = result["volumes"][0] + assert isinstance(volume, operators_models.PersistentVolume) assert ( - result["volumes"][0].volume_name + volume.volume_name == "shared-workspace-" + jupyter_model.configuration["workspace"] ) @@ -38,16 +44,18 @@ def persistent_volume_exists(self, name: str) -> bool: @pytest.mark.usefixtures("project_user", "jupyter_model") def test_jupyter_volume_mount_not_found( jupyter_tool: tools_models.DatabaseTool, - user: users_models.DatabaseUser, - db: orm.Session, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): class MockOperator: # pylint: disable=unused-argument def persistent_volume_exists(self, name: str) -> bool: return False + configuration_hook_request.operator = MockOperator() # type: ignore + configuration_hook_request.tool = jupyter_tool + result = jupyter_hook.JupyterIntegration().configuration_hook( - db=db, user=user, tool=jupyter_tool, operator=MockOperator() + configuration_hook_request ) assert not result["volumes"] @@ -60,14 +68,12 @@ def persistent_volume_exists(self, name: str) -> bool: @pytest.mark.usefixtures("jupyter_model") def test_jupyter_volume_mount_without_project_access( jupyter_tool: tools_models.DatabaseTool, - user: users_models.DatabaseUser, - db: orm.Session, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): - class MockOperator: - pass + configuration_hook_request.tool = jupyter_tool result = jupyter_hook.JupyterIntegration().configuration_hook( - db=db, user=user, tool=jupyter_tool, operator=MockOperator() + configuration_hook_request ) assert not result["volumes"] diff --git a/backend/tests/sessions/hooks/test_networking_hook.py b/backend/tests/sessions/hooks/test_networking_hook.py index 93934c255c..8219e7ad04 100644 --- a/backend/tests/sessions/hooks/test_networking_hook.py +++ b/backend/tests/sessions/hooks/test_networking_hook.py @@ -5,14 +5,13 @@ import kubernetes.client import pytest -from capellacollab.sessions import models as sessions_models -from capellacollab.sessions import operators +from capellacollab.sessions.hooks import interface as session_hooks_interface from capellacollab.sessions.hooks import networking as networking_hook -from capellacollab.users import models as users_models def test_network_policy_created( - user: users_models.DatabaseUser, monkeypatch: pytest.MonkeyPatch + monkeypatch: pytest.MonkeyPatch, + post_session_creation_hook_request: session_hooks_interface.PostSessionCreationHookRequest, ): network_policy_counter = 0 @@ -32,16 +31,15 @@ def mock_create_namespaced_network_policy( ) networking_hook.NetworkingIntegration().post_session_creation_hook( - session_id="test", - operator=operators.KubernetesOperator(), - user=user, + post_session_creation_hook_request ) assert network_policy_counter == 1 def test_network_policy_deleted( - session: sessions_models.DatabaseSession, monkeypatch: pytest.MonkeyPatch + monkeypatch: pytest.MonkeyPatch, + pre_session_termination_hook_request: session_hooks_interface.PreSessionTerminationHookRequest, ): network_policy_del_counter = 0 @@ -61,8 +59,7 @@ def mock_delete_namespaced_network_policy( ) networking_hook.NetworkingIntegration().pre_session_termination_hook( - operator=operators.KubernetesOperator(), - session=session, + pre_session_termination_hook_request ) assert network_policy_del_counter == 1 diff --git a/backend/tests/sessions/hooks/test_persistent_workspace.py b/backend/tests/sessions/hooks/test_persistent_workspace.py index 56c6ca303f..597039ce27 100644 --- a/backend/tests/sessions/hooks/test_persistent_workspace.py +++ b/backend/tests/sessions/hooks/test_persistent_workspace.py @@ -11,41 +11,34 @@ from capellacollab.sessions import operators from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.hooks import persistent_workspace -from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models from capellacollab.users.workspaces import crud as users_workspaces_crud from capellacollab.users.workspaces import models as users_workspaces_models def test_persistent_workspace_mounting_not_allowed( - db: orm.Session, - tool: tools_models.DatabaseTool, - test_user: users_models.DatabaseUser, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): - tool.config.persistent_workspaces.mounting_enabled = False + configuration_hook_request.tool.config.persistent_workspaces.mounting_enabled = ( + False + ) with pytest.raises(sessions_exceptions.WorkspaceMountingNotAllowedError): persistent_workspace.PersistentWorkspaceHook().configuration_hook( - db=db, - operator=operators.KubernetesOperator(), - user=test_user, - session_type=sessions_models.SessionType.PERSISTENT, - tool=tool, + configuration_hook_request ) def persistent_workspace_mounting_readonly_session( - db: orm.Session, - tool: tools_models.DatabaseTool, - test_user: users_models.DatabaseUser, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) + response = ( persistent_workspace.PersistentWorkspaceHook().configuration_hook( - db=db, - operator=operators.KubernetesOperator(), - user=test_user, - session_type=sessions_models.SessionType.READONLY, - tool=tool, + configuration_hook_request ) ) @@ -54,9 +47,9 @@ def persistent_workspace_mounting_readonly_session( def test_workspace_is_created( db: orm.Session, - tool: tools_models.DatabaseTool, test_user: users_models.DatabaseUser, monkeypatch: pytest.MonkeyPatch, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): created_volumes = 0 volume_name = None @@ -80,12 +73,11 @@ def mock_create_namespaced_persistent_volume_claim( assert ( len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 0 ) + + configuration_hook_request.operator = operators.KubernetesOperator() + configuration_hook_request.user = test_user persistent_workspace.PersistentWorkspaceHook().configuration_hook( - db=db, - operator=operators.KubernetesOperator(), - user=test_user, - session_type=sessions_models.SessionType.PERSISTENT, - tool=tool, + configuration_hook_request ) assert created_volumes == 1 assert isinstance(volume_name, str) @@ -97,10 +89,10 @@ def mock_create_namespaced_persistent_volume_claim( def test_existing_workspace_is_mounted( db: orm.Session, - tool: tools_models.DatabaseTool, test_user: users_models.DatabaseUser, user_workspace: users_workspaces_models.DatabaseWorkspace, monkeypatch: pytest.MonkeyPatch, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): created_volumes = 0 volume_name = None @@ -120,12 +112,11 @@ def mock_create_namespaced_persistent_volume_claim(self, ns, pvc): assert ( len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 1 ) + + configuration_hook_request.user = test_user + configuration_hook_request.operator = operators.KubernetesOperator() persistent_workspace.PersistentWorkspaceHook().configuration_hook( - db=db, - operator=operators.KubernetesOperator(), - user=test_user, - session_type=sessions_models.SessionType.PERSISTENT, - tool=tool, + configuration_hook_request ) assert created_volumes == 1 assert isinstance(volume_name, str) diff --git a/backend/tests/sessions/hooks/test_pre_authentiation_hook.py b/backend/tests/sessions/hooks/test_pre_authentiation_hook.py index 50ef300b58..72b08f82e6 100644 --- a/backend/tests/sessions/hooks/test_pre_authentiation_hook.py +++ b/backend/tests/sessions/hooks/test_pre_authentiation_hook.py @@ -1,29 +1,22 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -import logging - import pytest from capellacollab.sessions import auth as sessions_auth -from capellacollab.sessions import models as sessions_models from capellacollab.sessions.hooks import authentication -from capellacollab.users import models as users_models +from capellacollab.sessions.hooks import interface as sessions_hooks_interface def test_pre_authentication_hook( - session: sessions_models.DatabaseSession, - user: users_models.DatabaseUser, - logger: logging.LoggerAdapter, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, monkeypatch: pytest.MonkeyPatch, ): private_key = sessions_auth.generate_private_key() monkeypatch.setattr(sessions_auth, "PRIVATE_KEY", private_key) result = authentication.PreAuthenticationHook().session_connection_hook( - db_session=session, - user=user, - logger=logger, + session_connection_hook_request ) assert "ccm_session_token" in result["cookies"] diff --git a/backend/tests/sessions/hooks/test_project_scope.py b/backend/tests/sessions/hooks/test_project_scope.py new file mode 100644 index 0000000000..6a9143771d --- /dev/null +++ b/backend/tests/sessions/hooks/test_project_scope.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from capellacollab.projects import models as projects_models +from capellacollab.sessions.hooks import interface as hooks_interface +from capellacollab.sessions.hooks import project_scope as project_scope_hook + + +def test_correct_workspace_with_project_scope( + configuration_hook_request: hooks_interface.ConfigurationHookRequest, + project: projects_models.DatabaseProject, +): + """Test that the correct workspace is set with the project scope""" + + configuration_hook_request.project_scope = project + result = project_scope_hook.ProjectScopeHook().configuration_hook( + configuration_hook_request + ) + + assert ( + result["environment"]["WORKSPACE_DIR"] + == f"/workspace/{project.slug}/tool-1" + ) diff --git a/backend/tests/sessions/hooks/test_provisioning_hook.py b/backend/tests/sessions/hooks/test_provisioning_hook.py index fe83c0c2cd..0c6ac69534 100644 --- a/backend/tests/sessions/hooks/test_provisioning_hook.py +++ b/backend/tests/sessions/hooks/test_provisioning_hook.py @@ -5,44 +5,50 @@ import pytest from sqlalchemy import orm +from capellacollab.projects import crud as projects_crud from capellacollab.projects import models as projects_models from capellacollab.projects.toolmodels import models as toolmodels_models from capellacollab.projects.toolmodels.modelsources.git import ( models as git_models, ) +from capellacollab.projects.toolmodels.provisioning import ( + crud as provisioning_crud, +) +from capellacollab.projects.toolmodels.provisioning import ( + models as provisioning_models, +) from capellacollab.sessions import exceptions as sessions_exceptions from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.hooks import provisioning as hooks_provisioning from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models +@pytest.mark.asyncio @pytest.mark.usefixtures("project_user") -def test_git_models_are_resolved_correctly( - db: orm.Session, - user: users_models.DatabaseUser, - capella_tool: tools_models.DatabaseTool, - capella_tool_version: tools_models.DatabaseVersion, +async def test_read_only_git_models_are_resolved_correctly( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): - """Make sure that the Git models are correctly translated to GIT_MODELS environment""" - - response = hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( - db=db, - tool=capella_tool, - tool_version=capella_tool_version, - user=user, - provisioning=[ - sessions_models.SessionProvisioningRequest( - project_slug=project.slug, - model_slug=capella_model.slug, - git_model_id=git_model.id, - revision="test", - deep_clone=False, - ) - ], + """Make sure that the Git models are correctly translated to environment""" + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="test", + deep_clone=False, + ) + ] + + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request ) expected_response_dict = { @@ -67,43 +73,37 @@ def test_git_models_are_resolved_correctly( ] -def test_provisioning_fails_missing_permission( - db: orm.Session, - user: users_models.DatabaseUser, - capella_tool: tools_models.DatabaseTool, - capella_tool_version: tools_models.DatabaseVersion, +@pytest.mark.asyncio +async def test_provisioning_fails_missing_permission( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """Make sure that provisioning fails when the user does not have the correct permissions""" + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] with pytest.raises(fastapi.HTTPException): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( - db=db, - tool=capella_tool, - tool_version=capella_tool_version, - user=user, - provisioning=[ - sessions_models.SessionProvisioningRequest( - project_slug=project.slug, - model_slug=capella_model.slug, - git_model_id=git_model.id, - revision="main", - deep_clone=False, - ) - ], + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request ) +@pytest.mark.asyncio @pytest.mark.usefixtures("project_user") -def test_provisioning_fails_too_many_models_requested( - db: orm.Session, - user: users_models.DatabaseUser, +async def test_provisioning_fails_too_many_models_requested( capella_tool: tools_models.DatabaseTool, - capella_tool_version: tools_models.DatabaseVersion, project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): capella_tool.config.provisioning.max_number_of_models = 1 @@ -115,58 +115,52 @@ def test_provisioning_fails_too_many_models_requested( deep_clone=False, ) + configuration_hook_request.provisioning = [ + session_provisioning_request, + session_provisioning_request, + ] with pytest.raises( sessions_exceptions.TooManyModelsRequestedToProvisionError ): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( - db=db, - tool=capella_tool, - tool_version=capella_tool_version, - user=user, - provisioning=[ - session_provisioning_request, - session_provisioning_request, - ], + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request ) -def test_tool_model_mismatch( - db: orm.Session, - user: users_models.DatabaseUser, - tool: tools_models.DatabaseTool, - tool_version: tools_models.DatabaseVersion, +@pytest.mark.asyncio +async def test_tool_model_mismatch( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, + tool_version: tools_models.DatabaseVersion, git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """Make sure that provisioning fails when the provided model doesn't match the selected tool""" + configuration_hook_request.tool_version = tool_version + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] with pytest.raises(sessions_exceptions.ToolAndModelMismatchError): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( - db=db, - tool=tool, - tool_version=tool_version, - user=user, - provisioning=[ - sessions_models.SessionProvisioningRequest( - project_slug=project.slug, - model_slug=capella_model.slug, - git_model_id=git_model.id, - revision="main", - deep_clone=False, - ) - ], + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request ) -def test_provision_session_with_compatible_tool_versions( +@pytest.mark.asyncio +async def test_read_only_provisioning_session_with_compatible_tool_versions( db: orm.Session, - admin: users_models.DatabaseUser, tool_version: tools_models.DatabaseVersion, - tool: tools_models.DatabaseTool, capella_tool_version: tools_models.DatabaseVersion, project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """Make sure that provisioning is successful when the tool is compatible with the tool of the model""" @@ -174,19 +168,222 @@ def test_provision_session_with_compatible_tool_versions( orm.attributes.flag_modified(tool_version, "config") db.commit() - response = hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( - db=db, - tool=tool, - tool_version=tool_version, - user=admin, - provisioning=[ - sessions_models.SessionProvisioningRequest( - project_slug=project.slug, - model_slug=capella_model.slug, - git_model_id=git_model.id, - revision="main", - deep_clone=False, - ) - ], + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) + + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] + configuration_hook_request.user.role = users_models.Role.ADMIN + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request ) assert response["environment"]["CAPELLACOLLAB_SESSION_PROVISIONING"] + + +@pytest.mark.asyncio +async def test_request_fails_if_provisioning_is_required( + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): + """Test that a request without provisioning information fails + + If the tool requires provisioning, but no provisioning information + is provided, the request should fail. + """ + + configuration_hook_request.tool.config.provisioning.required = True + + with pytest.raises(sessions_exceptions.ProvisioningRequiredError): + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ls_remote", "project_user") +async def test_persistent_provisioning_init( + db: orm.Session, + project: projects_models.DatabaseProject, + user: users_models.DatabaseUser, + capella_model: toolmodels_models.DatabaseToolModel, + git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): + """Test the initial provisioning of a persistent provisioning""" + + configuration_hook_request.project_scope = project + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + provisioning = provisioning_crud.get_model_provisioning( + db, capella_model, user + ) + assert provisioning is not None + assert ( + provisioning.commit_hash == "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847" + ) + + init_provisioning = response["init_environment"][ + "CAPELLACOLLAB_PROVISIONING" + ] + assert len(init_provisioning) == 1 + assert ( + init_provisioning[0]["revision"] + == "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847" + ) + + session_provisioning = response["environment"][ + "CAPELLACOLLAB_SESSION_PROVISIONING" + ] + assert len(session_provisioning) == 1 + assert "password" not in session_provisioning[0] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("project_user") +async def test_persistent_provisioning( + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, + git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, + provisioning: provisioning_models.DatabaseModelProvisioning, +): + """Test skipping the provisioning if already provisioned""" + + configuration_hook_request.project_scope = project + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + assert len(response["init_environment"]["CAPELLACOLLAB_PROVISIONING"]) == 0 + + session_provisioning = response["environment"][ + "CAPELLACOLLAB_SESSION_PROVISIONING" + ] + assert len(session_provisioning) == 1 + assert "password" not in session_provisioning[0] + assert session_provisioning[0]["revision"] == provisioning.commit_hash + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("project_user") +async def test_persistent_provisioning_required_project_scope( + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, + git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): + """Test that a request without project_scope is declined""" + + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] + + with pytest.raises(sessions_exceptions.ProjectScopeRequiredError): + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("project_user") +async def test_persistent_provisioning_project_mismatch( + db: orm.Session, + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.DatabaseToolModel, + git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): + """If a provisioning is requested for a another project, fail.""" + + project2 = projects_crud.create_project(db, "project2") + configuration_hook_request.project_scope = project2 + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + revision="main", + deep_clone=False, + ) + ] + + with pytest.raises(sessions_exceptions.ProjectAndModelMismatchError): + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ls_remote", "project_user") +async def test_provisioning_fallback_without_revision( + db: orm.Session, + project: projects_models.DatabaseProject, + user: users_models.DatabaseUser, + capella_model: toolmodels_models.DatabaseToolModel, + git_model: git_models.DatabaseGitModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): + """Test that the provisioning falls back to the default revision + if no provision is provided""" + + configuration_hook_request.project_scope = project + configuration_hook_request.provisioning = [ + sessions_models.SessionProvisioningRequest( + project_slug=project.slug, + model_slug=capella_model.slug, + git_model_id=git_model.id, + deep_clone=False, + ) + ] + + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( + configuration_hook_request + ) + + provisioning = provisioning_crud.get_model_provisioning( + db, capella_model, user + ) + assert provisioning is not None + assert provisioning.revision == git_model.revision + + session_provisioning = response["environment"][ + "CAPELLACOLLAB_SESSION_PROVISIONING" + ] + assert len(session_provisioning) == 1 + assert ( + session_provisioning[0]["revision"] + == "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847" + ) diff --git a/backend/tests/sessions/hooks/test_pure_variants.py b/backend/tests/sessions/hooks/test_pure_variants.py index 6f23039e6e..a16f3e79fe 100644 --- a/backend/tests/sessions/hooks/test_pure_variants.py +++ b/backend/tests/sessions/hooks/test_pure_variants.py @@ -18,7 +18,6 @@ from capellacollab.sessions.hooks import pure_variants from capellacollab.tools import crud as tools_crud from capellacollab.tools import models as tools_models -from capellacollab.users import models as users_models @pytest.fixture(name="pure_variants_tool") @@ -51,16 +50,17 @@ def fixture_pure_variants_model( def test_skip_for_read_only_sessions( - db: orm.Session, - user: users_models.DatabaseUser, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """pure::variants has no read-only support Therefore, the hook also shouldn't do anything for read-only sessions. """ - + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) result = pure_variants.PureVariantsIntegration().configuration_hook( - db, user, sessions_models.SessionType.READONLY + configuration_hook_request ) assert result == hooks_interface.ConfigurationHookResult() @@ -69,8 +69,8 @@ def test_skip_for_read_only_sessions( @pytest.mark.usefixtures("project_user") def test_skip_when_user_has_no_pv_access( db: orm.Session, - user: users_models.DatabaseUser, pure_variants_model: toolmodels_models.DatabaseToolModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """If a user has no access to a project with a model that has the pure::variants restriction enabled, skip loading of the license. @@ -84,7 +84,7 @@ def test_skip_when_user_has_no_pv_access( ) result = pure_variants.PureVariantsIntegration().configuration_hook( - db, user, sessions_models.SessionType.PERSISTENT + configuration_hook_request ) assert "environment" not in result @@ -95,8 +95,8 @@ def test_skip_when_user_has_no_pv_access( @pytest.mark.usefixtures("project_user") def test_skip_when_license_server_not_configured( db: orm.Session, - user: users_models.DatabaseUser, pure_variants_model: toolmodels_models.DatabaseToolModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """If no pure::variants license is configured in the settings, skip loading of the license. @@ -111,7 +111,7 @@ def test_skip_when_license_server_not_configured( ) result = pure_variants.PureVariantsIntegration().configuration_hook( - db, user, sessions_models.SessionType.PERSISTENT + configuration_hook_request ) assert "environment" not in result @@ -122,8 +122,8 @@ def test_skip_when_license_server_not_configured( @pytest.mark.usefixtures("project_user", "pure_variants_license") def test_inject_pure_variants_license_information( db: orm.Session, - user: users_models.DatabaseUser, pure_variants_model: toolmodels_models.DatabaseToolModel, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """Test that the configured license information is properly injected in the session container. @@ -138,7 +138,7 @@ def test_inject_pure_variants_license_information( ) result = pure_variants.PureVariantsIntegration().configuration_hook( - db, user, sessions_models.SessionType.PERSISTENT + configuration_hook_request ) assert result["environment"] == { diff --git a/backend/tests/sessions/hooks/test_session_preparation.py b/backend/tests/sessions/hooks/test_session_preparation.py index ab12c919a4..2677774b34 100644 --- a/backend/tests/sessions/hooks/test_session_preparation.py +++ b/backend/tests/sessions/hooks/test_session_preparation.py @@ -5,33 +5,32 @@ from capellacollab.sessions import models as sessions_models from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.hooks import session_preparation -from capellacollab.tools import models as tools_models -def test_session_preparation_hook(tool: tools_models.DatabaseTool): +def test_session_preparation_hook( + configuration_hook_request: hooks_interface.ConfigurationHookRequest, +): """Test that the session preparation hook registers a shared volume""" - + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) result = session_preparation.GitRepositoryCloningHook().configuration_hook( - session_type=sessions_models.SessionType.READONLY, - session_id="session-id", - tool=tool, + configuration_hook_request ) assert len(result["volumes"]) == 1 assert len(result["init_volumes"]) == 1 assert result["volumes"][0] == result["init_volumes"][0] - assert result["volumes"][0].name == "session-id-models" + assert result["volumes"][0].name == "nxylxqbmfqwvswlqlcbsirvrt-models" def test_session_preparation_hook_with_persistent_session( - tool: tools_models.DatabaseTool, + configuration_hook_request: hooks_interface.ConfigurationHookRequest, ): """Test that the session preparation hook doesn't do anything for persistent sessions""" result = session_preparation.GitRepositoryCloningHook().configuration_hook( - session_type=sessions_models.SessionType.PERSISTENT, - session_id="session-id", - tool=tool, + configuration_hook_request ) assert result == hooks_interface.ConfigurationHookResult() diff --git a/backend/tests/sessions/hooks/test_t4c_hook.py b/backend/tests/sessions/hooks/test_t4c_hook.py index e058e96ae2..3a26641d1d 100644 --- a/backend/tests/sessions/hooks/test_t4c_hook.py +++ b/backend/tests/sessions/hooks/test_t4c_hook.py @@ -53,16 +53,12 @@ def fixture_mock_add_user_to_repository_failed( @responses.activate @pytest.mark.usefixtures("t4c_model", "project_user") def test_t4c_configuration_hook( - db: orm.Session, user: users_models.DatabaseUser, - capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert result["environment"]["T4C_LICENCE_SECRET"] @@ -76,21 +72,20 @@ def test_t4c_configuration_hook( @responses.activate @pytest.mark.usefixtures("t4c_model") def test_t4c_configuration_hook_as_admin( - db: orm.Session, - admin: users_models.DatabaseUser, - capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): + configuration_hook_request.user.role = users_models.Role.ADMIN result = t4c.T4CIntegration().configuration_hook( - db=db, - user=admin, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert result["environment"]["T4C_LICENCE_SECRET"] assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 - assert result["environment"]["T4C_USERNAME"] == admin.name + assert ( + result["environment"]["T4C_USERNAME"] + == configuration_hook_request.user.name + ) assert result["environment"]["T4C_PASSWORD"] assert not result["warnings"] assert mock_add_user_to_repository.call_count == 1 @@ -100,11 +95,11 @@ def test_t4c_configuration_hook_as_admin( @pytest.mark.usefixtures("t4c_model") def test_t4c_configuration_hook_with_same_repository_used_twice( db: orm.Session, - admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, t4c_repository: settings_t4c_repositories_models.DatabaseT4CRepository, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): model = toolmodels_models.PostToolModel( name="test2", description="test", tool_id=capella_tool_version.tool.id @@ -113,11 +108,9 @@ def test_t4c_configuration_hook_with_same_repository_used_twice( db, project, model, capella_tool_version.tool, capella_tool_version ) models_t4c_crud.create_t4c_model(db, db_model, t4c_repository, "default2") + configuration_hook_request.user.role = users_models.Role.ADMIN result = t4c.T4CIntegration().configuration_hook( - db=db, - user=admin, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 @@ -128,16 +121,14 @@ def test_t4c_configuration_hook_with_same_repository_used_twice( @responses.activate @pytest.mark.usefixtures("t4c_model", "project_user") def test_t4c_configuration_hook_failure( - db: orm.Session, user: users_models.DatabaseUser, - capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository_failed: responses.BaseResponse, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): + """Test behavior when T4C API call fails""" + result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert result["environment"]["T4C_LICENCE_SECRET"] @@ -154,17 +145,14 @@ def test_configuration_hook_for_archived_project( project: projects_models.DatabaseProject, db: orm.Session, user: users_models.DatabaseUser, - capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): project.is_archived = True db.commit() result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert not result["environment"]["T4C_LICENCE_SECRET"] @@ -180,18 +168,15 @@ def test_configuration_hook_for_archived_project( def test_configuration_hook_as_rw_user( db: orm.Session, user: users_models.DatabaseUser, - capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, project_user: projects_users_models.ProjectUserAssociation, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): project_user.permission = projects_users_models.ProjectUserPermission.READ db.commit() result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=capella_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert not result["environment"]["T4C_LICENCE_SECRET"] @@ -209,6 +194,7 @@ def test_configuration_hook_for_compatible_tool( user: users_models.DatabaseUser, capella_tool_version: tools_models.DatabaseVersion, mock_add_user_to_repository: responses.BaseResponse, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): custom_tool = tools_crud.create_tool( db, tools_models.CreateTool(name="custom") @@ -223,11 +209,9 @@ def test_configuration_hook_for_compatible_tool( db, custom_tool, create_compatible_tool_version ) + configuration_hook_request.tool_version = compatible_tool_version result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=compatible_tool_version, - session_type=sessions_models.SessionType.PERSISTENT, + configuration_hook_request ) assert result["environment"]["T4C_LICENCE_SECRET"] @@ -239,28 +223,25 @@ def test_configuration_hook_for_compatible_tool( def test_t4c_configuration_hook_non_persistent( - db: orm.Session, - user: users_models.DatabaseUser, - tool_version: tools_models.DatabaseVersion, + configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest, ): + configuration_hook_request.session_type = ( + sessions_models.SessionType.READONLY + ) result = t4c.T4CIntegration().configuration_hook( - db=db, - user=user, - tool_version=tool_version, - session_type=sessions_models.SessionType.READONLY, + configuration_hook_request ) assert result == sessions_hooks_interface.ConfigurationHookResult() def test_t4c_connection_hook_non_persistent( - user: users_models.DatabaseUser, session: sessions_models.DatabaseSession, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): session.type = sessions_models.SessionType.READONLY result = t4c.T4CIntegration().session_connection_hook( - db_session=session, - user=user, + session_connection_hook_request ) assert result == sessions_hooks_interface.SessionConnectionHookResult() @@ -268,8 +249,8 @@ def test_t4c_connection_hook_non_persistent( def test_t4c_connection_hook_shared_session( db: orm.Session, - user: users_models.DatabaseUser, session: sessions_models.DatabaseSession, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): user2 = users_crud.create_user( db, @@ -280,21 +261,19 @@ def test_t4c_connection_hook_shared_session( ) session.owner = user2 result = t4c.T4CIntegration().session_connection_hook( - db_session=session, - user=user, + session_connection_hook_request ) assert result == sessions_hooks_interface.SessionConnectionHookResult() def test_t4c_connection_hook( - user: users_models.DatabaseUser, session: sessions_models.DatabaseSession, + session_connection_hook_request: sessions_hooks_interface.SessionConnectionHookRequest, ): session.environment = {"T4C_PASSWORD": "test"} result = t4c.T4CIntegration().session_connection_hook( - db_session=session, - user=user, + session_connection_hook_request ) assert result["t4c_token"] == "test" @@ -303,18 +282,21 @@ def test_t4c_connection_hook( @responses.activate @pytest.mark.usefixtures("t4c_model", "project_user") def test_t4c_termination_hook( - db: orm.Session, session: sessions_models.DatabaseSession, user: users_models.DatabaseUser, t4c_instance: t4c_models.DatabaseT4CInstance, capella_tool_version: tools_models.DatabaseVersion, + pre_session_termination_hook_request: sessions_hooks_interface.PreSessionTerminationHookRequest, ): session.version = capella_tool_version + pre_session_termination_hook_request.session = session rsp = responses.delete( f"{t4c_instance.rest_api}/users/{user.name}?repositoryName=test", status=200, ) - t4c.T4CIntegration().pre_session_termination_hook(db=db, session=session) + t4c.T4CIntegration().pre_session_termination_hook( + pre_session_termination_hook_request + ) assert rsp.call_count == 1 diff --git a/backend/tests/sessions/test_session_environment.py b/backend/tests/sessions/test_session_environment.py index 8871192b5c..763ccb4c99 100644 --- a/backend/tests/sessions/test_session_environment.py +++ b/backend/tests/sessions/test_session_environment.py @@ -4,9 +4,11 @@ import logging import pytest +from sqlalchemy import orm from capellacollab import config from capellacollab.config import models as config_models +from capellacollab.core import models as core_models from capellacollab.sessions import crud as sessions_crud from capellacollab.sessions import hooks as sessions_hooks from capellacollab.sessions import models as sessions_models @@ -18,7 +20,7 @@ class MockOperator: - environment = {} + environment: dict[str, str] = {} # pylint: disable=unused-argument def start_session(self, environment, *args, **kwargs): @@ -103,15 +105,17 @@ def fixture_patch_irrelevant_request_session_calls( ) +@pytest.mark.asyncio @pytest.mark.usefixtures( "patch_irrelevant_request_session_calls", "tool_version" ) -def test_environment_behaviour( +async def test_environment_behavior( monkeypatch: pytest.MonkeyPatch, operator: MockOperator, logger: logging.LoggerAdapter, + db: orm.Session, ): - """Test the behaviour of environment variables + """Test the behavior of environment variables The rules are: @@ -123,7 +127,7 @@ def test_environment_behaviour( """ class GetSessionsReponseMock: - warnings = [] + warnings: list[core_models.Message] = [] response = GetSessionsReponseMock() @@ -133,7 +137,7 @@ class GetSessionsReponseMock: lambda *args: response, ) - sessions_routes.request_session( + await sessions_routes.request_session( sessions_models.PostSessionRequest( tool_id=0, version_id=0, @@ -144,8 +148,8 @@ class GetSessionsReponseMock: users_models.DatabaseUser( name="test", idp_identifier="test", role=users_models.Role.USER ), - None, - operator, + db, + operator, # type: ignore logger, ) @@ -175,7 +179,6 @@ class GetSessionsReponseMock: def test_environment_resolution_before_stage(logger: logging.LoggerAdapter): - environment = {"TEST": [{"test": "test2"}]} rules = { "TEST2": tools_models.ToolSessionEnvironment( diff --git a/backend/tests/sessions/test_session_hooks.py b/backend/tests/sessions/test_session_hooks.py index 376d9ba9bb..0258e99065 100644 --- a/backend/tests/sessions/test_session_hooks.py +++ b/backend/tests/sessions/test_session_hooks.py @@ -39,57 +39,37 @@ def create_persistent_volume(self, *args, **kwargs): class TestSessionHook(hooks_interface.HookRegistration): configuration_hook_counter = 0 + async_configuration_hook_counter = 0 post_session_creation_hook_counter = 0 session_connection_hook_counter = 0 post_termination_hook_counter = 0 def configuration_hook( - self, - db: orm.Session, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - tool: tools_models.DatabaseTool, - tool_version: tools_models.DatabaseVersion, - session_type: sessions_models.SessionType, - connection_method: tools_models.ToolSessionConnectionMethod, - provisioning: list[sessions_models.SessionProvisioningRequest], - session_id: str, - **kwargs, + self, request: hooks_interface.ConfigurationHookRequest ) -> hooks_interface.ConfigurationHookResult: self.configuration_hook_counter += 1 return hooks_interface.ConfigurationHookResult() + async def async_configuration_hook( + self, request: hooks_interface.ConfigurationHookRequest + ) -> hooks_interface.ConfigurationHookResult: + self.async_configuration_hook_counter += 1 + return hooks_interface.ConfigurationHookResult() + def post_session_creation_hook( - self, - session_id: str, - session: k8s.Session, - db_session: sessions_models.DatabaseSession, - operator: operators.KubernetesOperator, - user: users_models.DatabaseUser, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + self, request: hooks_interface.PostSessionCreationHookRequest ) -> hooks_interface.PostSessionCreationHookResult: self.post_session_creation_hook_counter += 1 return hooks_interface.PostSessionCreationHookResult() def session_connection_hook( - self, - db: orm.Session, - db_session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - logger: logging.LoggerAdapter, - **kwargs, + self, request: hooks_interface.SessionConnectionHookRequest ) -> hooks_interface.SessionConnectionHookResult: self.session_connection_hook_counter += 1 return hooks_interface.SessionConnectionHookResult() def pre_session_termination_hook( - self, - db: orm.Session, - operator: operators.KubernetesOperator, - session: sessions_models.DatabaseSession, - connection_method: tools_models.ToolSessionConnectionMethod, - **kwargs, + self, request: hooks_interface.PreSessionTerminationHookRequest ) -> hooks_interface.PreSessionTerminationHookResult: self.post_termination_hook_counter += 1 return hooks_interface.PreSessionTerminationHookResult() @@ -123,14 +103,16 @@ def get_mock_operator(): del __main__.app.dependency_overrides[operators.get_operator] +@pytest.mark.asyncio @pytest.mark.usefixtures("mock_session_injection", "tool_version") -def test_hook_calls_during_session_request( +async def test_hook_calls_during_session_request( monkeypatch: pytest.MonkeyPatch, db: orm.Session, user: users_models.DatabaseUser, mockoperator: MockOperator, session_hook: TestSessionHook, tool: tools_models.DatabaseTool, + logger: logging.LoggerAdapter, ): """Test that the relevant session hooks are called during a session request. @@ -145,7 +127,7 @@ def test_hook_calls_during_session_request( lambda *args, **kwargs: "placeholder", ) - sessions_routes.request_session( + await sessions_routes.request_session( sessions_models.PostSessionRequest( tool_id=0, version_id=0, @@ -156,10 +138,11 @@ def test_hook_calls_during_session_request( user, db, mockoperator, # type: ignore - logging.getLogger("test"), + logger, ) assert session_hook.configuration_hook_counter == 1 + assert session_hook.async_configuration_hook_counter == 1 assert session_hook.post_session_creation_hook_counter == 1 assert session_hook.session_connection_hook_counter == 0 assert session_hook.post_termination_hook_counter == 0 @@ -168,6 +151,7 @@ def test_hook_calls_during_session_request( def test_hook_call_during_session_connection( db: orm.Session, session: sessions_models.DatabaseSession, + logger: logging.LoggerAdapter, ): """Test that the session hook is called when connecting to a session""" @@ -176,7 +160,7 @@ def test_hook_call_during_session_connection( db, session, session.owner, - logging.getLogger("test"), + logger, ) diff --git a/backend/tests/sessions/test_session_routes.py b/backend/tests/sessions/test_session_routes.py index aa8982226b..d8839be0ff 100644 --- a/backend/tests/sessions/test_session_routes.py +++ b/backend/tests/sessions/test_session_routes.py @@ -11,6 +11,7 @@ from sqlalchemy import orm from capellacollab.__main__ import app +from capellacollab.projects import models as projects_models from capellacollab.projects.toolmodels import models as toolmodels_models from capellacollab.projects.toolmodels.modelsources.git import ( models as git_models, @@ -251,3 +252,61 @@ def test_own_sessions( # Check that environment and config are not exposed assert "environment" not in response.json()[0] assert "config" not in response.json()[0] + + +@pytest.mark.usefixtures("kubernetes", "user", "mock_session_injection") +def test_request_session_connection_method_fallback( + client: testclient.TestClient, + tool_version: tools_models.DatabaseVersion, + tool: tools_models.DatabaseTool, +): + """Test missing connection_method_id in the request + + If the connection_method_id is missing in the request, + the first applicable connection method of the tool should be used. + """ + + response = client.post( + "/api/v1/sessions", + json={ + "tool_id": tool.id, + "version_id": tool_version.id, + "session_type": "persistent", + }, + ) + + assert response.status_code == 200 + assert "id" in response.json() + assert ( + response.json()["connection_method_id"] + == tool.config.connection.methods[0].id + ) + + +@pytest.mark.usefixtures("user") +def test_project_slug_for_unauthorized_project( + client: testclient.TestClient, + tool_version: tools_models.DatabaseVersion, + tool: tools_models.DatabaseTool, + project: projects_models.DatabaseProject, +): + """Test project_slug without permission in the request + + Test that a request is declined if the user has no access to the project. + """ + + response = client.post( + "/api/v1/sessions", + json={ + "tool_id": tool.id, + "version_id": tool_version.id, + "session_type": "persistent", + "project_slug": project.slug, + }, + ) + + assert response.status_code == 403 + assert ( + response.json()["detail"]["err_code"] + == "REQUIRED_PROJECT_ROLE_NOT_MET" + ) diff --git a/backend/tests/settings/fixtures.py b/backend/tests/settings/fixtures.py new file mode 100644 index 0000000000..feff4c897f --- /dev/null +++ b/backend/tests/settings/fixtures.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import asyncio + +import pytest + +from capellacollab.settings.modelsources.git import core as instances_git_core + + +@pytest.fixture(name="mock_ls_remote") +def fixture_mock_ls_remote( + monkeypatch: pytest.MonkeyPatch, +): + ls_remote = ( + "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847 HEAD\n" + "e0f83d8d57ec1552c5fb76c83f7dff7f0ff86631 refs/heads/test-branch1\n" + "76c71f5468f6e444317146c6c9a3e00033974a1c refs/heads/test-branch2\n" + "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847 refs/heads/main\n" + "ea10a5a82f31807d89c1bb7fc61dcd331e49f8fc refs/pull/100/head\n" + "47cda65668eb258c5e84a8ffd43909ba4fac2661 refs/tags/v1.0.0\n" + "bce139e467d3d60bd21a4097c78e86a87e1a5d21 refs/tags/v1.1.0\n" + ) + + # pylint: disable=unused-argument + def mock_ls_remote(*args, **kwargs): + f: asyncio.Future = asyncio.Future() + f.set_result(ls_remote) + return f + + monkeypatch.setattr( + instances_git_core, "_ls_remote_command", mock_ls_remote + ) diff --git a/backend/tests/settings/test_git_instances.py b/backend/tests/settings/test_git_instances.py index f28fd0451b..a9b7a3607a 100644 --- a/backend/tests/settings/test_git_instances.py +++ b/backend/tests/settings/test_git_instances.py @@ -1,13 +1,11 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -import asyncio import pytest from fastapi import testclient from sqlalchemy import orm -from capellacollab.settings.modelsources.git import core as git_core from capellacollab.settings.modelsources.git import crud as git_crud from capellacollab.settings.modelsources.git import models as git_models @@ -99,28 +97,10 @@ def test_delete_git_instance( assert not git_crud.get_git_instance_by_id(db, git_instance.id) -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("user", "mock_ls_remote") def test_fetch_revisions( - monkeypatch: pytest.MonkeyPatch, client: testclient.TestClient, ): - ls_remote = [ - "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847 HEAD", - "e0f83d8d57ec1552c5fb76c83f7dff7f0ff86631 refs/heads/test-branch1", - "76c71f5468f6e444317146c6c9a3e00033974a1c refs/heads/test-branch2", - "0665eb5bf5dc3a7bdcb30b4354c85eddde2bd847 refs/heads/main", - "ea10a5a82f31807d89c1bb7fc61dcd331e49f8fc refs/pull/100/head", - "47cda65668eb258c5e84a8ffd43909ba4fac2661 refs/tags/v1.0.0", - "bce139e467d3d60bd21a4097c78e86a87e1a5d21 refs/tags/v1.1.0", - ] - - # pylint: disable=unused-argument - def mock_ls_remote(*args, **kwargs): - f: asyncio.Future = asyncio.Future() - f.set_result(ls_remote) - return f - - monkeypatch.setattr(git_core, "ls_remote", mock_ls_remote) response = client.post( "/api/v1/settings/modelsources/git/revisions", diff --git a/backend/tests/test_event_creation.py b/backend/tests/test_event_creation.py index c24e4ff234..cf2fc54174 100644 --- a/backend/tests/test_event_creation.py +++ b/backend/tests/test_event_creation.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import uuid + import pytest import sqlalchemy as sa from fastapi import testclient @@ -17,7 +19,12 @@ reason: str = "TestReason" -def test_create_admin_user_by_system(db): +@pytest.fixture(name="unique_username") +def fixture_unique_username() -> str: + return str(uuid.uuid1()) + + +def test_create_admin_user_by_system(db: orm.Session): user = users_crud.get_user_by_name(db, config.config.initial.admin) assert user is not None @@ -36,7 +43,12 @@ def test_create_admin_user_by_system(db): assert event.user_id == user.id -def test_create_user_created_event(client, db, executor_name, unique_username): +def test_create_user_created_event( + client: testclient.TestClient, + db: orm.Session, + executor_name: str, + unique_username: str, +): executor = users_crud.create_user( db, executor_name, executor_name, None, users_models.Role.ADMIN ) @@ -160,45 +172,39 @@ def test_create_assign_user_role_event( ), ], ) +@pytest.mark.usefixtures("admin") def test_create_user_added_to_project_event( client: testclient.TestClient, db: orm.Session, - executor_name: str, - unique_username: str, + admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, permission: projects_users_models.ProjectUserPermission, expected_permission_event_type: events_models.EventType, + user2: users_models.DatabaseUser, ): - executor = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - response = client.post( f"/api/v1/projects/{project.slug}/users/", json={ "role": projects_users_models.ProjectUserRole.USER.value, "permission": permission.value, - "username": user.name, + "username": user2.name, "reason": reason, }, ) assert response.status_code == 200 - events = get_events_by_user_id(db, user.id) + events = get_events_by_user_id(db, user2.id) assert len(events) == 3 user_added_event = events[0] assert ( user_added_event.event_type == events_models.EventType.ADDED_TO_PROJECT ) - assert user_added_event.executor_id == executor.id + assert user_added_event.executor_id == admin.id assert user_added_event.reason == reason assert user_added_event.project_id == project.id - assert user_added_event.user_id == user.id + assert user_added_event.user_id == user2.id assert ( events[1].event_type @@ -210,71 +216,57 @@ def test_create_user_added_to_project_event( def test_create_user_removed_from_project_event( client: testclient.TestClient, db: orm.Session, - executor_name: str, - unique_username: str, + user2: users_models.DatabaseUser, + admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, ): - executor = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - projects_users_crud.add_user_to_project( db, project, - user, + user2, projects_users_models.ProjectUserRole.USER, projects_users_models.ProjectUserPermission.READ, ) response = client.request( "DELETE", - f"/api/v1/projects/{project.slug}/users/{user.id}", + f"/api/v1/projects/{project.slug}/users/{user2.id}", content=reason, headers={"Content-Type": "text/plain"}, ) assert response.status_code == 204 - events = get_events_by_user_id(db, user.id) + events = get_events_by_user_id(db, user2.id) assert len(events) == 1 event = events[0] assert event.event_type == events_models.EventType.REMOVED_FROM_PROJECT - assert event.executor_id == executor.id + assert event.executor_id == admin.id assert event.reason == reason assert event.project_id == project.id - assert event.user_id == user.id + assert event.user_id == user2.id def test_create_manager_added_to_project_event( client: testclient.TestClient, db: orm.Session, - executor_name: str, - unique_username: str, + admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, + user2: users_models.DatabaseUser, ): - executor = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - response = client.post( f"/api/v1/projects/{project.slug}/users/", json={ "role": projects_users_models.ProjectUserRole.MANAGER.value, "permission": projects_users_models.ProjectUserPermission.READ.value, - "username": user.name, + "username": user2.name, "reason": reason, }, ) - events = get_events_by_user_id(db, user.id) + events = get_events_by_user_id(db, user2.id) assert response.status_code == 200 assert len(events) == 2 @@ -287,10 +279,10 @@ def test_create_manager_added_to_project_event( ], ): assert event.event_type == expected_event_type - assert event.executor_id == executor.id + assert event.executor_id == admin.id assert event.reason == reason assert event.project_id == project.id - assert event.user_id == user.id + assert event.user_id == user2.id @pytest.mark.parametrize( @@ -312,29 +304,23 @@ def test_create_user_permission_change_event( client: testclient.TestClient, db: orm.Session, executor_name: str, - unique_username: str, + admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, initial_permission: projects_users_models.ProjectUserPermission, target_permission: projects_users_models.ProjectUserPermission, expected_permission_event_type: events_models.EventType, + user2: users_models.DatabaseUser, ): - executor = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - projects_users_crud.add_user_to_project( db, project, - user, + user2, projects_users_models.ProjectUserRole.USER, initial_permission, ) response = client.patch( - f"/api/v1/projects/{project.slug}/users/{user.id}", + f"/api/v1/projects/{project.slug}/users/{user2.id}", json={ "permission": target_permission.value, "reason": reason, @@ -343,16 +329,16 @@ def test_create_user_permission_change_event( assert response.status_code == 204 - events = get_events_by_user_id(db, user.id) + events = get_events_by_user_id(db, user2.id) assert len(events) == 1 event = events[0] assert event.event_type == expected_permission_event_type - assert event.executor_id == executor.id + assert event.executor_id == admin.id assert event.reason == reason assert event.project_id == project.id - assert event.user_id == user.id + assert event.user_id == user2.id @pytest.mark.parametrize( @@ -373,30 +359,23 @@ def test_create_user_permission_change_event( def test_create_user_role_change_event( client: testclient.TestClient, db: orm.Session, - executor_name: str, - unique_username: str, + admin: users_models.DatabaseUser, project: projects_models.DatabaseProject, initial_role: projects_users_models.ProjectUserRole, target_role: projects_users_models.ProjectUserRole, expected_role_event_type: events_models.EventType, + user2: users_models.DatabaseUser, ): - executor = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - user = users_crud.create_user( - db, unique_username, unique_username, None, users_models.Role.USER - ) - projects_users_crud.add_user_to_project( db, project, - user, + user2, initial_role, projects_users_models.ProjectUserPermission.READ, ) response = client.patch( - f"/api/v1/projects/{project.slug}/users/{user.id}", + f"/api/v1/projects/{project.slug}/users/{user2.id}", json={ "role": target_role.value, "reason": reason, @@ -405,16 +384,16 @@ def test_create_user_role_change_event( assert response.status_code == 204 - events = get_events_by_user_id(db, user.id) + events = get_events_by_user_id(db, user2.id) assert len(events) == 1 event = events[0] assert event.event_type == expected_role_event_type - assert event.executor_id == executor.id + assert event.executor_id == admin.id assert event.reason == reason assert event.project_id == project.id - assert event.user_id == user.id + assert event.user_id == user2.id def get_events_by_username( diff --git a/backend/tests/users/fixtures.py b/backend/tests/users/fixtures.py index 65d9c1639b..4c99f34979 100644 --- a/backend/tests/users/fixtures.py +++ b/backend/tests/users/fixtures.py @@ -31,11 +31,6 @@ async def cookie_passthrough(self, request: fastapi.Request): return name -@pytest.fixture(name="unique_username") -def fixture_unique_username() -> str: - return str(uuid.uuid1()) - - @pytest.fixture(name="basic_user") def fixture_basic_user( db: orm.Session, executor_name: str @@ -59,6 +54,13 @@ def get_mock_own_user(): del app.dependency_overrides[users_injectables.get_own_user] +@pytest.fixture(name="user2") +def fixture_user2(db: orm.Session) -> users_models.DatabaseUser: + return users_crud.create_user( + db, "user2", "user2", None, users_models.Role.USER + ) + + @pytest.fixture(name="admin") def fixture_admin( db: orm.Session, executor_name: str diff --git a/docs/docs/admin/tools/configuration.md b/docs/docs/admin/tools/configuration.md index e60f844c32..964ccbf00c 100644 --- a/docs/docs/admin/tools/configuration.md +++ b/docs/docs/admin/tools/configuration.md @@ -167,6 +167,13 @@ variables can be used by the tool: The tool has to set the `Content-Security-Policy` header to `frame-ancestors self {CAPELLACOLLAB_ORIGIN_HOST}`. Otherwise, the session viewer can't be used with the tool! + + `WORKSPACE_DIR` + `/workspace` + + The directory of the (persistent) workspace the application should work with. + + diff --git a/docs/docs/user/sessions/types/index.md b/docs/docs/user/sessions/types/index.md index 13c864cf4f..6c387f6ae3 100644 --- a/docs/docs/user/sessions/types/index.md +++ b/docs/docs/user/sessions/types/index.md @@ -3,49 +3,48 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -You can choose two different types of workspaces: +# Session Types -### Persistent Capella/Papyrus Sessions +The Capella Collaboration Manager offers different Session Types: -Persistent Sessions allows you to use personal workspace within Capella. Your -personal workspace will be stored and is part of our backup routines. However, -we still advise not to save any important information there. By default, we +## Persistent Sessions + +Persistent Sessions will store your work in the `/workspace` folder. Persistent +Sessions allows you to use personal workspace within Capella. By default, we will request 20GB of storage for your personal workspace. If your project uses the T4C-workflow, we will suggest all visible models in the T4C connection dialog. -???+ tip - - Starting the first time, your personal workspace will be empty. - Please close the `Welcome`-dialog first: - ![Close Welcome dialog](screenshots/close_welcome_dialog.png) - -!!! info +!!! warning - Only work stored in the `/workspace` folder (default workspace folder) will - be persistent. + Only work stored in the `/workspace` folder (and subdirectories) will + be persistent. If you store your work in another folder, it will be lost + when the session is closed. -### Persistent Jupyter Notebooks +### Provisioned Sessions -Jupyter notebooks allow you to programmatically explore (capella) models. -You'll use the same shared workspace as with persistent Capella/Papyrus -sessions. The same restrictions as with Capella sessions apply here. +Provisioned Sessions are a special type of Persistent Sessions. They are +available in projects and can be used to initialize a workspace with content +from Git repositories. After the initial provisioning, changes will be saved. +You can reset the state at any time to the latest state of the Git repository. -!!! info +Provisioned sessions are a good alternative to persistent sessions if you only +have read-only access in a project but want to make changes on the model that +you want to integrate later. - Jupyter notebooks use the same `/workspace` folder as is used with - Capella sessions. +The provisioned workspace will saved in your personal workspace in the folder +`/workspace/{project_slug}/tool-{tool_id}`. -### Readonly Capella/Papyrus Sessions +## Read-Only Sessions -Readonly Sessions allow you to read information from models without consuming a -license. +Read-Only Sessions allow you to read information from models without the risk +of changing the model. The can be useful if you want to review a model or don't +have permissions to write to the model. -!!! warning +!!! info - Read-only sessions work only with linked git models. Please ask your project - lead if your model has read-only support. + Read-only sessions only work for models with linked Git repositories. !!! danger diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js index dc72f5ebbb..569a061ad4 100644 --- a/frontend/.prettierrc.js +++ b/frontend/.prettierrc.js @@ -7,6 +7,8 @@ module.exports = { plugins: [ require.resolve("prettier-plugin-tailwindcss"), require.resolve("@trivago/prettier-plugin-sort-imports"), + require.resolve("prettier-plugin-classnames"), + require.resolve("prettier-plugin-merge"), ], importOrder: ["^[./]"], importOrderParserPlugins: ["typescript", "decorators-legacy"], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 956634bd27..347cfe5116 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", "monaco-editor": "^0.52.0", + "ngx-markdown": "^18.1.0", "ngx-skeleton-loader": "^9.0.0", "ngx-toastr": "^19.0.0", "npm": "^10.9.0", @@ -68,6 +69,8 @@ "npm-check-updates": "^17.1.9", "postcss": "^8.4.47", "prettier": "^3.3.3", + "prettier-plugin-classnames": "^0.7.4", + "prettier-plugin-merge": "^0.7.1", "prettier-plugin-tailwindcss": "^0.6.8", "storybook": "^8.4.0", "tailwindcss": "^3.4.14", @@ -815,6 +818,30 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.4.1.tgz", + "integrity": "sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==", + "license": "MIT", + "optional": true, + "dependencies": { + "package-manager-detector": "^0.2.0", + "tinyexec": "^0.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -2777,6 +2804,57 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.0.tgz", + "integrity": "sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg==", + "license": "MIT", + "optional": true + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/@compodoc/compodoc": { "version": "1.1.26", "resolved": "https://registry.npmjs.org/@compodoc/compodoc/-/compodoc-1.1.26.tgz", @@ -3207,6 +3285,19 @@ "node": ">=6" } }, + "node_modules/@compodoc/compodoc/node_modules/marked": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-7.0.3.tgz", + "integrity": "sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/@compodoc/compodoc/node_modules/readdirp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", @@ -3910,9 +4001,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", - "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", + "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", "dev": true, "license": "MIT", "engines": { @@ -3993,6 +4084,20 @@ "node": ">=18.18.0" } }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4008,9 +4113,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.0.tgz", + "integrity": "sha512-xnRgu9DxZbkWak/te3fcytNyp8MTbuiZIaueg2rgEvBuN55n04nwLYLU9TX/VVlusc9L2ZNXi99nUFNkHXtr5g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4021,6 +4126,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT", + "optional": true + }, + "node_modules/@iconify/utils": { + "version": "2.1.33", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.1.33.tgz", + "integrity": "sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@antfu/install-pkg": "^0.4.0", + "@antfu/utils": "^0.7.10", + "@iconify/types": "^2.0.0", + "debug": "^4.3.6", + "kolorist": "^1.8.0", + "local-pkg": "^0.5.0", + "mlly": "^1.7.1" + } + }, "node_modules/@inquirer/checkbox": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", @@ -4584,6 +4712,16 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.3.0.tgz", + "integrity": "sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA==", + "license": "MIT", + "optional": true, + "dependencies": { + "langium": "3.0.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -5421,9 +5559,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.0.tgz", - "integrity": "sha512-xQ84mDIl+jyDpjt8SnCfhqVECQu7k1dLyhiAi983Tp5nyW8KRJa/tEATDLOCpz1eL9AMf2WjAypi+vIiNIul8w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.1.tgz", + "integrity": "sha512-D6KohTIA4JCHNol1X7Whp4LpOVU4cS5FfyOorwYo/WIzpHrUYc4Pw/+ex6DOmU/kgrk14mr8d9obVehKW7iNtA==", "dev": true, "license": "MIT", "dependencies": { @@ -5438,7 +5576,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-actions/node_modules/@types/uuid": { @@ -5463,9 +5601,9 @@ } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.4.0.tgz", - "integrity": "sha512-2LpA7Ja7s76rFjSQHTPhbfmwsCmAuyU5k05CIbbUxM+iBVOaBXUYLaoi8dl448W/o/rmNHeW5YCtxzmMPlScrQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.4.1.tgz", + "integrity": "sha512-DIT1E9R9Sds8KTC+0m2X5cVa8hTNcKY1XKYTI9QdzQvdZzOt+K93AJqq2x8k5glingqUVpB6v2fSDmCUXp4+4g==", "dev": true, "license": "MIT", "dependencies": { @@ -5478,13 +5616,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-controls": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.4.0.tgz", - "integrity": "sha512-KoqwWHi6cUv1WXcANH4l175kNkuFPVhexP/8F9tE9uhv2xHNx5cTefmB174dWpfOO2H3IdUk0RuMWjOZFpztqQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.4.1.tgz", + "integrity": "sha512-3ahbYdDx7iFUd4X1KelMSuPqVnladc0bH4m6DQZyN+wkRxdRlOD6iOGuOe2qi1Gv0b2VuVAt253i75tK/TPNLw==", "dev": true, "license": "MIT", "dependencies": { @@ -5497,20 +5635,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-docs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.4.0.tgz", - "integrity": "sha512-n/tAu8xmfdxTkr7ooDM3h+QwDyP9eoKoKuaKXfiPPevrFk0FXRw5KzNhTHTlHniJ2LD+gyaomPGV6D2oBl1KIg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.4.1.tgz", + "integrity": "sha512-yPD/NssJf7pMJzaKvma02C6yX8ykPVnEjhRbNYcBNM8s8g/cT5JkROvIB+FOb4T81yhdfbGg9bGkpAXGX270IQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.4.0", - "@storybook/csf-plugin": "8.4.0", - "@storybook/react-dom-shim": "8.4.0", + "@storybook/blocks": "8.4.1", + "@storybook/csf-plugin": "8.4.1", + "@storybook/react-dom-shim": "8.4.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", "ts-dedent": "^2.0.0" @@ -5520,25 +5658,25 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-essentials": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.4.0.tgz", - "integrity": "sha512-45CI0LpNr8ASHEckxbW/osgnsFMWl847S9rALNQUAN3VaqlDQeF/VIDt1s9vtV9ZYNHASxPFmW4qjgylxv8HpQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.4.1.tgz", + "integrity": "sha512-Hmb5fpVzQgyCacDtHeE7HJqIfolzeOnedsLyJVYVpKns/uOWXqpDuU8Fc0s3yTjr1QPIRKtbqV1STxoyXj2how==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-actions": "8.4.0", - "@storybook/addon-backgrounds": "8.4.0", - "@storybook/addon-controls": "8.4.0", - "@storybook/addon-docs": "8.4.0", - "@storybook/addon-highlight": "8.4.0", - "@storybook/addon-measure": "8.4.0", - "@storybook/addon-outline": "8.4.0", - "@storybook/addon-toolbars": "8.4.0", - "@storybook/addon-viewport": "8.4.0", + "@storybook/addon-actions": "8.4.1", + "@storybook/addon-backgrounds": "8.4.1", + "@storybook/addon-controls": "8.4.1", + "@storybook/addon-docs": "8.4.1", + "@storybook/addon-highlight": "8.4.1", + "@storybook/addon-measure": "8.4.1", + "@storybook/addon-outline": "8.4.1", + "@storybook/addon-toolbars": "8.4.1", + "@storybook/addon-viewport": "8.4.1", "ts-dedent": "^2.0.0" }, "funding": { @@ -5546,13 +5684,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.4.0.tgz", - "integrity": "sha512-tshX/2HnPzGQ9Kza2DARNfirBRhE/Ts7bldbhMiJu20YhJD1jQzXSDEX1cCgHsDc8HKYOsV/Kuu5WDzp/1i97w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.4.1.tgz", + "integrity": "sha512-BBkUd6+i7lUEWZwoJDlUIwrs7EXkk+EoREUi27iiA1Lilw+NNhoC3kcBmj3+MccjRyeMeIWAgYyXF5qeB2s/JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5563,19 +5701,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-interactions": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.4.0.tgz", - "integrity": "sha512-yXPAyGRjElYZ0ObUo7Ipww4CwgScc2FXMxeQHKSZ+9wuDOU8uSaWpINB++8nS6yPZyhHeUqgzGCF/w3ZusNvzA==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.4.1.tgz", + "integrity": "sha512-rMxKehtQogV6Scjb/oqMFM0Mwn8NJRuGFDRJE3TBijNSJ2HPJms+xXp8KVZJengadlsF5HFwQBbnZzIeFDQRLw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.4.0", - "@storybook/test": "8.4.0", + "@storybook/instrumenter": "8.4.1", + "@storybook/test": "8.4.1", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, @@ -5584,13 +5722,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-links": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.4.0.tgz", - "integrity": "sha512-6MxHHfeshQLA0q40/djK7LrDDLtYt/FnKbNWgH4fbj281IELn1BTYc8cihyN7CZEWyqRqusi6EFpGFgO3LWBgA==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.4.1.tgz", + "integrity": "sha512-wg83rNKo6mq5apV7f1qMn4q8xZ8wVx/42EEWxTOmnM37Q5kXltEBu+rUyBpPNDU8zBuXr/MRKIhK5h2k4WfWcg==", "dev": true, "license": "MIT", "dependencies": { @@ -5604,7 +5742,7 @@ }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.4.0" + "storybook": "^8.4.1" }, "peerDependenciesMeta": { "react": { @@ -5613,9 +5751,9 @@ } }, "node_modules/@storybook/addon-measure": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.4.0.tgz", - "integrity": "sha512-Zews/03IL/UUJMaheduGxJKG1mEwfpGq7SP1RtK0kK3l/yh6kVcKG63RXw5zVEoDwG4wzuuH9vi06Mlzhu8/rA==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.4.1.tgz", + "integrity": "sha512-Pg1ROj29hKt7grL/HmbIJ10WrkZf1Unx35SsP373bkPQ1ggYi9oxGqtfNchTF2zCb1xUpIikLYSJgkwdjqWxhA==", "dev": true, "license": "MIT", "dependencies": { @@ -5627,13 +5765,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-outline": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.4.0.tgz", - "integrity": "sha512-qZdHaWq/DXoVycKzcynvVxg3MNzavsGCuq9HUl2X/oBKNii00NEZgYVLo4dQ8iDNlmykuJ9ReyXKBOKF7AU+9w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.4.1.tgz", + "integrity": "sha512-LPZ0gGHfbru66Lkw1whnc3F/r1hfnoORBoF98Hp+cjH34gR4t8te6xq5qSiupRUULGdSLdBRs/4EGRBeELfVjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5645,13 +5783,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.4.0.tgz", - "integrity": "sha512-fXDeLsAweC1/roe5qNys+pBrjf1Mxof/7O/dZtQZJtcKox4WwzgirxexFFAZLfXOE9awm5svzo0YWYxWk+Lfwg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.4.1.tgz", + "integrity": "sha512-yrzX6BFeJM5KFY0+ZAYfRax2QgWi2e5vF6yPz+MGIPr4nhHay0wTkOHhkBhIPBjQO9x0vqc7MS2EBDydCBWqlg==", "dev": true, "license": "MIT", "funding": { @@ -5659,13 +5797,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.4.0.tgz", - "integrity": "sha512-hbHJzz7PcZ/bazUH3nAdG9yP3CUfF+wPdDwzcqSEVBRjdWSLZ4DHAtB0wajqhUoCsiRehg9avft1NokAc+KOgg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.4.1.tgz", + "integrity": "sha512-O6DcuUfXQTytjl7mj4ld4ZX9x2pUUWKUx1TxiuMuH0EKb612RyYcdpXpDQQwsIzLV/f2BOetk9jmO2/MymfbWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5676,23 +5814,23 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/angular": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/angular/-/angular-8.4.0.tgz", - "integrity": "sha512-e1V4x18MvGaxLfGIRq3cUGkFqUR0tc6MZ7FAu/DQOgl6aJ9P8YAthVot9fYu8i2uY3Zb/dOWWNJt4zqw7D806w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/angular/-/angular-8.4.1.tgz", + "integrity": "sha512-UVegOQv1w7KAROa7QqBe4g4xJ01lSKbF7ML2jfEL02c+NQ/sLvlEeziAy0R0jOr5/LE1q+zv6t8F2yqN67XVqw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-webpack5": "8.4.0", - "@storybook/components": "^8.4.0", - "@storybook/core-webpack": "8.4.0", + "@storybook/builder-webpack5": "8.4.1", + "@storybook/components": "8.4.1", + "@storybook/core-webpack": "8.4.1", "@storybook/global": "^5.0.0", - "@storybook/manager-api": "^8.4.0", - "@storybook/preview-api": "^8.4.0", - "@storybook/theming": "^8.4.0", + "@storybook/manager-api": "8.4.1", + "@storybook/preview-api": "8.4.1", + "@storybook/theming": "8.4.1", "@types/node": "^22.0.0", "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", @@ -5727,7 +5865,7 @@ "@angular/platform-browser": ">=15.0.0 < 19.0.0", "@angular/platform-browser-dynamic": ">=15.0.0 < 19.0.0", "rxjs": "^6.0.0 || ^7.4.0", - "storybook": "^8.4.0", + "storybook": "^8.4.1", "typescript": "^4.0.0 || ^5.0.0", "zone.js": ">= 0.11.1 < 1.0.0" }, @@ -5738,9 +5876,9 @@ } }, "node_modules/@storybook/blocks": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.4.0.tgz", - "integrity": "sha512-LeXsZLTNcmKtgt0ZRdgzBa2Z8A5CH3gGyjG7QT3M+3yH9fVAXB2XplKOIejDsvR9jSBww3mKXyabX12NVZKz0A==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.4.1.tgz", + "integrity": "sha512-C4w5T5fhg0iONXozHQ1bh9im2Lr1BiY7Bj/9XoFjkc5YeCzxlMpujFA6Nmo4ToUFW90QbvKN7/QVhbrtY9O1Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -5755,7 +5893,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.4.0" + "storybook": "^8.4.1" }, "peerDependenciesMeta": { "react": { @@ -5767,13 +5905,13 @@ } }, "node_modules/@storybook/builder-webpack5": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.4.0.tgz", - "integrity": "sha512-NVPEB31x1LU73ghgPaynY603Pi0MKPlM/YovevlwZtTIU9st+DSEss1qSjC0As2Lq/bHZTJu+jhTCIB76MK7wQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.4.1.tgz", + "integrity": "sha512-rqSJcxcYiQyceNFSrT9qnI6hrW4/petb1n+oN8nG5HrRsl0zxOVzamMVyNzZxrAMKvq+VMJtLe1rQi8FnJNunw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "8.4.0", + "@storybook/core-webpack": "8.4.1", "@types/node": "^22.0.0", "@types/semver": "^7.3.4", "browser-assert": "^1.2.1", @@ -5804,7 +5942,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" }, "peerDependenciesMeta": { "typescript": { @@ -5878,9 +6016,9 @@ } }, "node_modules/@storybook/components": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.4.0.tgz", - "integrity": "sha512-o2jPW05YN2rbSLNMzPV769c4zCy3Vn0DhJbIQZsxUmUXAMX/n1+V1jlV3kbY0kCjiI6i/PH7i6PJnxICdJ35mQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.4.1.tgz", + "integrity": "sha512-bMPclbBhrWxhFlwqrC/h4fPLl05ouoi5D8SkQTHjeVxWN9eDnMVi76xM0YDct302Z3f0x5S3plIulp+4XRxrvg==", "dev": true, "license": "MIT", "funding": { @@ -5888,13 +6026,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "node_modules/@storybook/core": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.0.tgz", - "integrity": "sha512-RlvkBNPPLbHtJQ5M3SKfLLtn5GssRBOLBbJLJf8HjraeDI+YRt+J9FVXqNa9aHhOGoxam+hFinmuy9gyMbPW1A==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.1.tgz", + "integrity": "sha512-q3Q4OFBj7MHHbIFYk/Beejlqv5j7CC3+VWhGcr0TK3SGvdCIZ7EliYuc5JIOgDlEPsnTIk+lkgWI4LAA9mLzSw==", "license": "MIT", "dependencies": { "@storybook/csf": "^0.1.11", @@ -5923,9 +6061,9 @@ } }, "node_modules/@storybook/core-webpack": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.4.0.tgz", - "integrity": "sha512-14UnJ7zFSLEyaBvYe7+K1t/TWJc41KxstMHgVxHyE6TDy9MGi+GLfmq2xB5OIVE4nxtjSon3tIOf/hVBrtbt0A==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.4.1.tgz", + "integrity": "sha512-TptbDGaj9a8wJMF4g+C8t02CXl4BSd0BA/qGWBvzn3j4FJqeQ/m8elOXLYZrPbQKI6PjP0J4ayHkXdX2h0/tUw==", "dev": true, "license": "MIT", "dependencies": { @@ -5937,7 +6075,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/csf": { @@ -5950,9 +6088,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.4.0.tgz", - "integrity": "sha512-l4vD1XboHh3nFOvcCIjoTED6bQZtRx+T/CUFfuZu3KEA7uJnXt/kUCXair9+Cgky9XvSEMvBPhoqa2dRx9ibBQ==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.4.1.tgz", + "integrity": "sha512-MdQkyq6mJ31lBsWCG9VNtx8O0oLSc5h4kvWDPyIP6Dn58K0Hv2z9qvxxSvtFjXA7ES9X+ivjorTke1kearifhg==", "dev": true, "license": "MIT", "dependencies": { @@ -5963,7 +6101,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/global": { @@ -5988,9 +6126,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.4.0.tgz", - "integrity": "sha512-iqQdH2lhyRVcCBnVOmjn/r/pFwIJ5X1isUkvyavwPf0KOB2bz+QuXXkvKdzirwQFu9jSLOEdu0v3Fr+PHUbIfA==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.4.1.tgz", + "integrity": "sha512-MgrhrLVW78jqno+Dh9h9Es06Ja3867TlrIUd8B3K3U1hsCFUQuFKXJBuGjNJF8U0QJY/aSIRnAgUBurHdVkPcw==", "dev": true, "license": "MIT", "dependencies": { @@ -6002,39 +6140,39 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/manager-api": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.4.0.tgz", - "integrity": "sha512-duYoAtx3VkTHpoXd+NaMqBQNqIovmbTN7w/244O0LWyhF6AmQXnrY1Z72rjvvpxY6c1boRs6YdDLXPKxGVeRxw==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.4.1.tgz", + "integrity": "sha512-7hb2k4zsp6lREGZbQ85QOlsC8EIMZXuY9Pg12VUgaZd+LmLjLuaqtrxRz3SwIgIWsRpFun9AHO0X37DmYNGTSw==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "node_modules/@storybook/preview-api": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.4.0.tgz", - "integrity": "sha512-Z9yduQRqzqeV85GEFyaTKtRtg/QYCb89bKhi4xcxY9l7DMAr7/lqpUxqngW5ogiNslusQzct3zI7os6INBlMFg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.4.1.tgz", + "integrity": "sha512-VdnESYfXCUasNtMd5s1Q8DPqMnAUdpROn8mE8UAD79Cy7DSNesI1q0SATuJqh5iYCT/+3Tpjfghsr2zC/mOh8w==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.4.0.tgz", - "integrity": "sha512-PYYZVdQ6/ts6hBMAwMEu4hfbyHFPzUYmVsZNtF2egaVJQ44xM4i1Zt+RJuo2NOt5VyBCfXJOs+lSIdmSBY2arw==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.4.1.tgz", + "integrity": "sha512-XhvuqkpqtcUjDA8XE4osq140SCddX3VHMdj+IwlrMdoSl32CAya01TH5YDDx6YMy6hM/QQbyVKaemG7RB/oU4Q==", "dev": true, "license": "MIT", "funding": { @@ -6044,19 +6182,19 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/test": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.4.0.tgz", - "integrity": "sha512-uHZ6+8RfEauwxi7Zy/LijfyIXrjCD7iTHmnTdT3BdP+2c/lDFAKXzHmbQJitefDFEgz1eHx/MArHZ8V3qu1ogg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.4.1.tgz", + "integrity": "sha512-najn9kCxB8NaHykhD7Fv+Iq0FnxmIJYOJlYiI8NMgVLwaSDFf6gnqAY6HHVPRqkhej8TuT1L2e2RxKqzWEB+mA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/csf": "^0.1.11", "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.4.0", + "@storybook/instrumenter": "8.4.1", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", @@ -6068,13 +6206,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.4.1" } }, "node_modules/@storybook/theming": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.4.0.tgz", - "integrity": "sha512-S7Iv5HMiYEJZlkQM0K9bxACLN7s8lCSG3M2CN6A82LSoXayFauuaPpn3LrNE2BvkTpdu17w19YiGbVYhPtRqsg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.4.1.tgz", + "integrity": "sha512-Sz24isryVFZaVahXkjgnCsMAQqQeeKg41AtLsldlYdesIo6fr5tc6/SkTUy+CYadK4Dkhqp+vVRDnwToYYRGhA==", "dev": true, "license": "MIT", "funding": { @@ -6082,7 +6220,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.4.0" + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "node_modules/@testing-library/dom": { @@ -6403,143 +6541,444 @@ "@types/node": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT", + "optional": true }, - "node_modules/@types/eslint__js": { - "version": "8.42.3", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", - "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", - "dev": true, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", "license": "MIT", + "optional": true, "dependencies": { - "@types/eslint": "*" + "@types/d3-selection": "*" } }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", "license": "MIT", + "optional": true, "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@types/d3-selection": "*" } }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", - "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", - "dev": true, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } + "optional": true }, - "node_modules/@types/express/node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "license": "MIT", + "optional": true, "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@types/d3-array": "*", + "@types/geojson": "*" } }, - "node_modules/@types/file-saver": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", - "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "dev": true, - "license": "MIT" + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT", + "optional": true }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, - "license": "MIT" + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "license": "MIT", + "optional": true }, - "node_modules/@types/http-proxy": { - "version": "1.17.15", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", - "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", - "dev": true, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", + "optional": true, "dependencies": { - "@types/node": "*" + "@types/d3-selection": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", - "dev": true, - "license": "MIT" + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT", + "optional": true }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT", + "optional": true }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", "license": "MIT", + "optional": true, "dependencies": { - "@types/node": "*" + "@types/d3-dsv": "*" } }, - "node_modules/@types/node": { - "version": "22.8.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.6.tgz", - "integrity": "sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==", - "devOptional": true, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", "license": "MIT", + "optional": true, "dependencies": { - "undici-types": "~6.19.8" + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint__js": { + "version": "8.42.3", + "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", + "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", + "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.7.tgz", + "integrity": "sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" } }, "node_modules/@types/node-forge": { @@ -6658,6 +7097,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -6680,9 +7126,9 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", - "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", "dev": true, "license": "MIT", "dependencies": { @@ -7208,7 +7654,7 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -8363,9 +8809,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001676", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001676.tgz", - "integrity": "sha512-Qz6zwGCiPghQXGJvgQAem79esjitvJ+CxSbSQkW9H/UX5hg8XM88d4lp2W+MEQ81j+Hip58Il+jGVdazk1z9cw==", + "version": "1.0.30001677", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz", + "integrity": "sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==", "dev": true, "funding": [ { @@ -8484,6 +8930,34 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -8770,6 +9244,18 @@ "node": ">= 12" } }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "optional": true, + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -9047,6 +9533,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT", + "optional": true + }, "node_modules/connect": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", @@ -9247,6 +9740,16 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "optional": true, + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -9397,18 +9900,589 @@ "dev": true, "license": "MIT" }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "node_modules/cytoscape": { + "version": "3.30.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.3.tgz", + "integrity": "sha512-HncJ9gGJbVtw7YXtIs3+6YAFSSiKsom0amWc33Z7QbylbY2JGMrA0yz4EwrdTScZxnwclXeEZHzO5pxoy0ZE4g==", "license": "MIT", + "optional": true, "engines": { - "node": ">= 14" + "node": ">=0.10" } }, - "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "optional": true, + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT", + "optional": true + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "optional": true, + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "optional": true, + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC", + "optional": true + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "optional": true, + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", + "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", + "license": "MIT", + "optional": true, + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT", + "optional": true + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "license": "MIT", "dependencies": { @@ -9582,6 +10656,16 @@ "node": ">= 14" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "optional": true, + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9591,6 +10675,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9650,7 +10741,17 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } }, "node_modules/dlv": { "version": "1.1.3", @@ -9733,6 +10834,13 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", @@ -9808,6 +10916,13 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/emoji-toolkit": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-9.0.1.tgz", + "integrity": "sha512-sMMNqKNLVHXJfIKoPbrRJwtYuysVNC9GlKetr72zE3SSVbHqoeDLWVrxP0uM0AE0qvdl3hbUk+tJhhwXZrDHaw==", + "license": "MIT", + "optional": true + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -10099,22 +11214,22 @@ } }, "node_modules/eslint": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", - "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", + "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.11.0", + "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.18.0", "@eslint/core": "^0.7.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.13.0", + "@eslint/js": "9.14.0", "@eslint/plugin-kit": "^0.2.0", - "@humanfs/node": "^0.16.5", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.3.1", + "@humanwhocodes/retry": "^0.4.0", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -10122,9 +11237,9 @@ "cross-spawn": "^7.0.2", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.1.0", - "eslint-visitor-keys": "^4.1.0", - "espree": "^10.2.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -11535,6 +12650,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "optional": true, + "dependencies": { + "delegate": "^3.1.2" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -11560,6 +12685,13 @@ "dev": true, "license": "MIT" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT", + "optional": true + }, "node_modules/hammerjs": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", @@ -12247,6 +13379,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -12890,6 +14032,33 @@ "source-map-support": "^0.5.5" } }, + "node_modules/katex": { + "version": "0.16.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", + "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/keycharm": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.2.0.tgz", @@ -12906,6 +14075,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "optional": true + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -12915,6 +14090,30 @@ "node": ">=0.10.0" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT", + "optional": true + }, + "node_modules/langium": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.0.0.tgz", + "integrity": "sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/launch-editor": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", @@ -12926,6 +14125,13 @@ "shell-quote": "^1.8.1" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT", + "optional": true + }, "node_modules/less": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", @@ -13195,6 +14401,23 @@ "node": ">= 12.13.0" } }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -13217,6 +14440,13 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT", + "optional": true + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -13589,16 +14819,16 @@ } }, "node_modules/marked": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-7.0.3.tgz", - "integrity": "sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==", - "dev": true, + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 16" + "node": ">= 18" } }, "node_modules/media-typer": { @@ -13661,6 +14891,63 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.4.0.tgz", + "integrity": "sha512-mxCfEYvADJqOiHfGpJXLs4/fAjHz448rH0pfY5fAoxiz70rQiDSzUUy4dNET2T08i46IVpjohPd6WWbzmRHiPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@braintree/sanitize-url": "^7.0.1", + "@iconify/utils": "^2.1.32", + "@mermaid-js/parser": "^0.3.0", + "@types/d3": "^7.4.3", + "@types/dompurify": "^3.0.5", + "cytoscape": "^3.29.2", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.11", + "dayjs": "^1.11.10", + "dompurify": "^3.0.11 <3.1.7", + "katex": "^0.16.9", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^13.0.2", + "roughjs": "^4.6.6", + "stylis": "^4.3.1", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.1" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz", + "integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==", + "license": "MIT", + "optional": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -14030,6 +15317,19 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mlly": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.2.tgz", + "integrity": "sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.12.1", + "pathe": "^1.1.2", + "pkg-types": "^1.2.0", + "ufo": "^1.5.4" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -14281,6 +15581,30 @@ "node": ">= 0.4.0" } }, + "node_modules/ngx-markdown": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-18.1.0.tgz", + "integrity": "sha512-n4HFSm5oqVMXFuD+WXIVkI6NyxD8Oubr4B3c9U1J7Ptr6t9DVnkNBax3yxWc+8Wli+FXTuGEnDXzB3sp7E9paA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "clipboard": "^2.0.11", + "emoji-toolkit": ">= 8.0.0 < 10.0.0", + "katex": "^0.16.0", + "mermaid": ">= 10.6.0 < 12.0.0", + "prismjs": "^1.28.0" + }, + "peerDependencies": { + "@angular/common": "^18.0.0", + "@angular/core": "^18.0.0", + "@angular/platform-browser": "^18.0.0", + "marked": ">= 9.0.0 < 13.0.0", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.14.0" + } + }, "node_modules/ngx-skeleton-loader": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-9.0.0.tgz", @@ -14759,9 +16083,9 @@ } }, "node_modules/npm-check-updates": { - "version": "17.1.9", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-17.1.9.tgz", - "integrity": "sha512-Gfv5S8NNJKTilM1gesFNYka6bUaBs5LnVyPjApXPQphHijrlLFDMw1uSmwYMZbvJSkLZSOx03e8CHcG0Td5SMA==", + "version": "17.1.10", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-17.1.10.tgz", + "integrity": "sha512-GnN6KbUzC8BpwsRYJntuumgCiagZ0+xxorvUJM9m06d7AlyK9lm3iFsAsnXF3VAZZzpD5QjZvWBwNze61Vywkw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -17904,9 +19228,9 @@ "license": "ISC" }, "node_modules/ordered-binary": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.2.tgz", - "integrity": "sha512-JTo+4+4Fw7FreyAvlSLjb1BBVaxEQAacmjD3jjuyPZclpbEghTvQZbXBb2qPd2LeIMxiHwXBZUcpmG2Gl/mDEA==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", + "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", "dev": true, "license": "MIT" }, @@ -18060,6 +19384,13 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.2.tgz", + "integrity": "sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==", + "license": "MIT", + "optional": true + }, "node_modules/pacote": { "version": "18.0.6", "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", @@ -18241,6 +19572,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT", + "optional": true + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -18322,6 +19660,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT", + "optional": true + }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", @@ -18505,6 +19850,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pkg-types": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz", + "integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.2", + "pathe": "^1.1.2" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT", + "optional": true + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -18818,6 +20193,45 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-classnames": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-classnames/-/prettier-plugin-classnames-0.7.4.tgz", + "integrity": "sha512-QDhISdUeYcwHHtsHBs+xImIeT+6DObStqxO1Aouv6biBXpdXa9OfOjeGiJ+GdcWYfN47WTbdcBKjFqYgflRVYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "prettier": "^2 || ^3", + "prettier-plugin-astro": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prettier-plugin-merge": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-merge/-/prettier-plugin-merge-0.7.1.tgz", + "integrity": "sha512-R3dSlv3kAlScjd/liWjTkGHcUrE4MBhPKKBxVOvHK7+FY2P5SEmLarZiD11VUEuaMRK0L7zqIurX6JcRYS9Y5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff": "5.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + } + }, "node_modules/prettier-plugin-tailwindcss": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz", @@ -18940,7 +20354,7 @@ "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -19827,6 +21241,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense", + "optional": true + }, "node_modules/rollup": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", @@ -19870,6 +21291,19 @@ "dev": true, "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -19907,6 +21341,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -20063,6 +21504,13 @@ } } }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT", + "optional": true + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -20963,12 +22411,12 @@ } }, "node_modules/storybook": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.0.tgz", - "integrity": "sha512-hLfXPtqfoQUMKVortxXdnQoUwDwtH85eSj9LbqGT/z1f/gLLYGNG3Mv3QbsRjHXhn+EfYffh7wuLpAn+Cicijw==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.1.tgz", + "integrity": "sha512-0tfFIFghjho9FtnFoiJMoxhcs2iIdvEF81GTSVnTsDVJrYA84nB+FxN3UY1fT0BcQ8BFlbf+OhSjZL7ufqqWKA==", "license": "MIT", "dependencies": { - "@storybook/core": "8.4.0" + "@storybook/core": "8.4.1" }, "bin": { "getstorybook": "bin/index.cjs", @@ -21778,6 +23226,13 @@ "webpack": "^5.0.0" } }, + "node_modules/stylis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.4.tgz", + "integrity": "sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now==", + "license": "MIT", + "optional": true + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -22255,12 +23710,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT", + "optional": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "license": "MIT", + "optional": true + }, "node_modules/tinyglobby": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", @@ -22449,7 +23918,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -22614,6 +24083,13 @@ } } }, + "node_modules/ufo": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "license": "MIT", + "optional": true + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -23526,6 +25002,61 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "optional": true, + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "optional": true, + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT", + "optional": true + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT", + "optional": true + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT", + "optional": true + }, "node_modules/wait-on": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 209498bde8..4ead18c1e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", "monaco-editor": "^0.52.0", + "ngx-markdown": "^18.1.0", "ngx-skeleton-loader": "^9.0.0", "ngx-toastr": "^19.0.0", "npm": "^10.9.0", @@ -75,6 +76,8 @@ "npm-check-updates": "^17.1.9", "postcss": "^8.4.47", "prettier": "^3.3.3", + "prettier-plugin-classnames": "^0.7.4", + "prettier-plugin-merge": "^0.7.1", "prettier-plugin-tailwindcss": "^0.6.8", "storybook": "^8.4.0", "tailwindcss": "^3.4.14", diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 4cbddf5a64..51c5085fee 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -11,6 +11,7 @@ import { PipelineRunWrapperComponent } from 'src/app/projects/models/backup-sett import { ViewLogsDialogComponent } from 'src/app/projects/models/backup-settings/view-logs-dialog/view-logs-dialog.component'; import { PipelineWrapperComponent } from 'src/app/projects/models/backup-settings/wrapper/pipeline-wrapper/pipeline-wrapper.component'; import { ModelRestrictionsComponent } from 'src/app/projects/models/model-restrictions/model-restrictions.component'; +import { CreateProjectToolsComponent } from 'src/app/projects/project-detail/create-project-tools/create-project-tools.component'; import { EditProjectMetadataComponent } from 'src/app/projects/project-detail/edit-project-metadata/edit-project-metadata.component'; import { SessionComponent } from 'src/app/sessions/session/session.component'; import { ConfigurationSettingsComponent } from 'src/app/settings/core/configuration-settings/configuration-settings.component'; @@ -107,6 +108,16 @@ export const routes: Routes = [ }, component: EditProjectMetadataComponent, }, + { + path: 'tools', + children: [ + { + path: 'link', + data: { breadcrumb: 'Link Tool' }, + component: CreateProjectToolsComponent, + }, + ], + }, { path: 'models', children: [ diff --git a/frontend/src/app/general/footer/footer.component.html b/frontend/src/app/general/footer/footer.component.html index 20345c7aa0..88d4548c1c 100644 --- a/frontend/src/app/general/footer/footer.component.html +++ b/frontend/src/app/general/footer/footer.component.html @@ -5,7 +5,8 @@
diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index c6482e0fe5..8b568210e3 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -14,9 +14,12 @@ api/projects-models-backups.service.ts api/projects-models-diagrams.service.ts api/projects-models-git.service.ts api/projects-models-model-complexity-badge.service.ts +api/projects-models-provisioning.service.ts +api/projects-models-readme.service.ts api/projects-models-restrictions.service.ts api/projects-models-t4-c.service.ts api/projects-models.service.ts +api/projects-tools.service.ts api/projects.service.ts api/sessions.service.ts api/settings-modelsources-git.service.ts @@ -92,6 +95,7 @@ model/minimal-tool-session-connection-method.ts model/minimal-tool-version-with-tool.ts model/minimal-tool.ts model/model-artifact-status.ts +model/model-provisioning.ts model/models.ts model/navbar-configuration-input-external-links-inner.ts model/navbar-configuration-input.ts @@ -123,12 +127,14 @@ model/pipeline-run.ts model/post-git-instance.ts model/post-git-model.ts model/post-project-request.ts +model/post-project-tool-request.ts model/post-project-user.ts model/post-session-request.ts model/post-token.ts model/post-tool-model.ts model/post-user.ts model/project-status.ts +model/project-tool.ts model/project-type.ts model/project-user-permission.ts model/project-user-role.ts @@ -166,6 +172,7 @@ model/simple-t4-c-model-with-repository.ts model/simple-t4-c-model-with-tool-model.ts model/simple-t4-c-repository-with-integrations.ts model/simple-t4-c-repository.ts +model/simple-tool-model-without-project.ts model/simple-tool-model.ts model/simple-tool-version.ts model/status-response.ts diff --git a/frontend/src/app/openapi/api/api.ts b/frontend/src/app/openapi/api/api.ts index 9990dc29f7..b6ce03b8da 100644 --- a/frontend/src/app/openapi/api/api.ts +++ b/frontend/src/app/openapi/api/api.ts @@ -41,10 +41,16 @@ export * from './projects-models-git.service'; import { ProjectsModelsGitService } from './projects-models-git.service'; export * from './projects-models-model-complexity-badge.service'; import { ProjectsModelsModelComplexityBadgeService } from './projects-models-model-complexity-badge.service'; +export * from './projects-models-provisioning.service'; +import { ProjectsModelsProvisioningService } from './projects-models-provisioning.service'; +export * from './projects-models-readme.service'; +import { ProjectsModelsREADMEService } from './projects-models-readme.service'; export * from './projects-models-restrictions.service'; import { ProjectsModelsRestrictionsService } from './projects-models-restrictions.service'; export * from './projects-models-t4-c.service'; import { ProjectsModelsT4CService } from './projects-models-t4-c.service'; +export * from './projects-tools.service'; +import { ProjectsToolsService } from './projects-tools.service'; export * from './sessions.service'; import { SessionsService } from './sessions.service'; export * from './settings-modelsources-git.service'; @@ -63,4 +69,4 @@ export * from './users-token.service'; import { UsersTokenService } from './users-token.service'; export * from './users-workspaces.service'; import { UsersWorkspacesService } from './users-workspaces.service'; -export const APIS = [AuthenticationService, ConfigurationService, EventsService, FeedbackService, HealthService, IntegrationsPureVariantsService, MetadataService, NavbarService, NoticesService, ProjectsService, ProjectsEventsService, ProjectsModelsService, ProjectsModelsBackupsService, ProjectsModelsDiagramsService, ProjectsModelsGitService, ProjectsModelsModelComplexityBadgeService, ProjectsModelsRestrictionsService, ProjectsModelsT4CService, SessionsService, SettingsModelsourcesGitService, SettingsModelsourcesT4CInstancesService, SettingsModelsourcesT4CLicenseServersService, ToolsService, UsersService, UsersSessionsService, UsersTokenService, UsersWorkspacesService]; +export const APIS = [AuthenticationService, ConfigurationService, EventsService, FeedbackService, HealthService, IntegrationsPureVariantsService, MetadataService, NavbarService, NoticesService, ProjectsService, ProjectsEventsService, ProjectsModelsService, ProjectsModelsBackupsService, ProjectsModelsDiagramsService, ProjectsModelsGitService, ProjectsModelsModelComplexityBadgeService, ProjectsModelsProvisioningService, ProjectsModelsREADMEService, ProjectsModelsRestrictionsService, ProjectsModelsT4CService, ProjectsToolsService, SessionsService, SettingsModelsourcesGitService, SettingsModelsourcesT4CInstancesService, SettingsModelsourcesT4CLicenseServersService, ToolsService, UsersService, UsersSessionsService, UsersTokenService, UsersWorkspacesService]; diff --git a/frontend/src/app/openapi/api/projects-models-provisioning.service.ts b/frontend/src/app/openapi/api/projects-models-provisioning.service.ts new file mode 100644 index 0000000000..6856a854b6 --- /dev/null +++ b/frontend/src/app/openapi/api/projects-models-provisioning.service.ts @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { HTTPValidationError } from '../model/http-validation-error'; +// @ts-ignore +import { ModelProvisioning } from '../model/model-provisioning'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class ProjectsModelsProvisioningService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Get Provisioning + * @param projectSlug + * @param modelSlug + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getProvisioning(projectSlug: string, modelSlug: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getProvisioning(projectSlug: string, modelSlug: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getProvisioning(projectSlug: string, modelSlug: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getProvisioning(projectSlug: string, modelSlug: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling getProvisioning.'); + } + if (modelSlug === null || modelSlug === undefined) { + throw new Error('Required parameter modelSlug was null or undefined when calling getProvisioning.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/models/${this.configuration.encodeParam({name: "modelSlug", value: modelSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/provisioning`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Reset Provisioning + * This will delete the provisioning data from the workspace. During the next session request, the existing provisioning will be overwritten in the workspace. + * @param projectSlug + * @param modelSlug + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public resetProvisioning(projectSlug: string, modelSlug: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public resetProvisioning(projectSlug: string, modelSlug: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public resetProvisioning(projectSlug: string, modelSlug: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public resetProvisioning(projectSlug: string, modelSlug: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling resetProvisioning.'); + } + if (modelSlug === null || modelSlug === undefined) { + throw new Error('Required parameter modelSlug was null or undefined when calling resetProvisioning.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/models/${this.configuration.encodeParam({name: "modelSlug", value: modelSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/provisioning`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/frontend/src/app/openapi/api/projects-models-readme.service.ts b/frontend/src/app/openapi/api/projects-models-readme.service.ts new file mode 100644 index 0000000000..e2790b8fd9 --- /dev/null +++ b/frontend/src/app/openapi/api/projects-models-readme.service.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { HTTPValidationError } from '../model/http-validation-error'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class ProjectsModelsREADMEService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Get Readme + * @param projectSlug + * @param modelSlug + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getReadme(projectSlug: string, modelSlug: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'text/markdown' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getReadme(projectSlug: string, modelSlug: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'text/markdown' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getReadme(projectSlug: string, modelSlug: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'text/markdown' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getReadme(projectSlug: string, modelSlug: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'text/markdown' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling getReadme.'); + } + if (modelSlug === null || modelSlug === undefined) { + throw new Error('Required parameter modelSlug was null or undefined when calling getReadme.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'text/markdown', + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/models/${this.configuration.encodeParam({name: "modelSlug", value: modelSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/readme`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/frontend/src/app/openapi/api/projects-tools.service.ts b/frontend/src/app/openapi/api/projects-tools.service.ts new file mode 100644 index 0000000000..1ea41b20fb --- /dev/null +++ b/frontend/src/app/openapi/api/projects-tools.service.ts @@ -0,0 +1,330 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { HTTPValidationError } from '../model/http-validation-error'; +// @ts-ignore +import { PostProjectToolRequest } from '../model/post-project-tool-request'; +// @ts-ignore +import { ProjectTool } from '../model/project-tool'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class ProjectsToolsService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Delete Tool From Project + * @param projectSlug + * @param projectToolId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public deleteToolFromProject(projectSlug: string, projectToolId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public deleteToolFromProject(projectSlug: string, projectToolId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteToolFromProject(projectSlug: string, projectToolId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteToolFromProject(projectSlug: string, projectToolId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling deleteToolFromProject.'); + } + if (projectToolId === null || projectToolId === undefined) { + throw new Error('Required parameter projectToolId was null or undefined when calling deleteToolFromProject.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/tools/${this.configuration.encodeParam({name: "projectToolId", value: projectToolId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Get Project Tools + * @param projectSlug + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getProjectTools(projectSlug: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getProjectTools(projectSlug: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getProjectTools(projectSlug: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getProjectTools(projectSlug: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling getProjectTools.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/tools`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Link Tool To Project + * @param projectSlug + * @param postProjectToolRequest + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public linkToolToProject(projectSlug: string, postProjectToolRequest: PostProjectToolRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public linkToolToProject(projectSlug: string, postProjectToolRequest: PostProjectToolRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public linkToolToProject(projectSlug: string, postProjectToolRequest: PostProjectToolRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public linkToolToProject(projectSlug: string, postProjectToolRequest: PostProjectToolRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (projectSlug === null || projectSlug === undefined) { + throw new Error('Required parameter projectSlug was null or undefined when calling linkToolToProject.'); + } + if (postProjectToolRequest === null || postProjectToolRequest === undefined) { + throw new Error('Required parameter postProjectToolRequest was null or undefined when calling linkToolToProject.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/projects/${this.configuration.encodeParam({name: "projectSlug", value: projectSlug, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/tools`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: postProjectToolRequest, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/frontend/src/app/openapi/model/model-provisioning.ts b/frontend/src/app/openapi/model/model-provisioning.ts new file mode 100644 index 0000000000..0e4fde3da9 --- /dev/null +++ b/frontend/src/app/openapi/model/model-provisioning.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +import { Session } from './session'; + + +export interface ModelProvisioning { + session: Session | null; + provisioned_at: string; + revision: string; + commit_hash: string; +} + diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index 9c61980e98..d38f023200 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -71,6 +71,7 @@ export * from './minimal-tool'; export * from './minimal-tool-session-connection-method'; export * from './minimal-tool-version-with-tool'; export * from './model-artifact-status'; +export * from './model-provisioning'; export * from './navbar-configuration-input'; export * from './navbar-configuration-input-external-links-inner'; export * from './navbar-configuration-output'; @@ -101,6 +102,7 @@ export * from './pipeline-run-status'; export * from './post-git-instance'; export * from './post-git-model'; export * from './post-project-request'; +export * from './post-project-tool-request'; export * from './post-project-user'; export * from './post-session-request'; export * from './post-token'; @@ -108,6 +110,7 @@ export * from './post-tool-model'; export * from './post-user'; export * from './project'; export * from './project-status'; +export * from './project-tool'; export * from './project-type'; export * from './project-user'; export * from './project-user-permission'; @@ -145,6 +148,7 @@ export * from './simple-t4-c-model-with-tool-model'; export * from './simple-t4-c-repository'; export * from './simple-t4-c-repository-with-integrations'; export * from './simple-tool-model'; +export * from './simple-tool-model-without-project'; export * from './simple-tool-version'; export * from './status-response'; export * from './submit-t4-c-model'; diff --git a/frontend/src/app/openapi/model/post-project-tool-request.ts b/frontend/src/app/openapi/model/post-project-tool-request.ts new file mode 100644 index 0000000000..b5bad04953 --- /dev/null +++ b/frontend/src/app/openapi/model/post-project-tool-request.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface PostProjectToolRequest { + tool_id: number; + tool_version_id: number; +} + diff --git a/frontend/src/app/openapi/model/post-session-request.ts b/frontend/src/app/openapi/model/post-session-request.ts index 2a02a00281..84eec84afd 100644 --- a/frontend/src/app/openapi/model/post-session-request.ts +++ b/frontend/src/app/openapi/model/post-session-request.ts @@ -17,11 +17,9 @@ export interface PostSessionRequest { tool_id: number; version_id: number; session_type?: SessionType; - /** - * The identifier of the connection method to use - */ - connection_method_id: string; + connection_method_id?: string | null; provisioning?: Array; + project_slug?: string | null; } export namespace PostSessionRequest { } diff --git a/frontend/src/app/openapi/model/project-tool.ts b/frontend/src/app/openapi/model/project-tool.ts new file mode 100644 index 0000000000..639cad051e --- /dev/null +++ b/frontend/src/app/openapi/model/project-tool.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +import { SimpleToolModelWithoutProject } from './simple-tool-model-without-project'; +import { SimpleToolVersion } from './simple-tool-version'; +import { Tool } from './tool'; + + +export interface ProjectTool { + id: number | null; + tool_version: SimpleToolVersion; + tool: Tool; + used_by: Array; +} + diff --git a/frontend/src/app/openapi/model/session-provisioning-request.ts b/frontend/src/app/openapi/model/session-provisioning-request.ts index 5d3605da58..a02dfa5f6d 100644 --- a/frontend/src/app/openapi/model/session-provisioning-request.ts +++ b/frontend/src/app/openapi/model/session-provisioning-request.ts @@ -15,7 +15,7 @@ export interface SessionProvisioningRequest { project_slug: string; model_slug: string; git_model_id: number; - revision: string; + revision?: string | null; deep_clone: boolean; } diff --git a/frontend/src/app/openapi/model/simple-tool-model-without-project.ts b/frontend/src/app/openapi/model/simple-tool-model-without-project.ts new file mode 100644 index 0000000000..e10293a214 --- /dev/null +++ b/frontend/src/app/openapi/model/simple-tool-model-without-project.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +import { GitModel } from './git-model'; + + +export interface SimpleToolModelWithoutProject { + id: number; + slug: string; + name: string; + git_models: Array | null; +} + diff --git a/frontend/src/app/openapi/model/tool-model-provisioning-input.ts b/frontend/src/app/openapi/model/tool-model-provisioning-input.ts index 96f7298626..b8a19462da 100644 --- a/frontend/src/app/openapi/model/tool-model-provisioning-input.ts +++ b/frontend/src/app/openapi/model/tool-model-provisioning-input.ts @@ -17,5 +17,9 @@ export interface ToolModelProvisioningInput { */ directory?: string; max_number_of_models?: number | null; + /** + * Specifies if a tool requires provisioning. If enabled and a session without provisioning is requested, it will be declined. + */ + required?: boolean; } diff --git a/frontend/src/app/openapi/model/tool-model-provisioning-output.ts b/frontend/src/app/openapi/model/tool-model-provisioning-output.ts index 968f2ed526..3f52f8babe 100644 --- a/frontend/src/app/openapi/model/tool-model-provisioning-output.ts +++ b/frontend/src/app/openapi/model/tool-model-provisioning-output.ts @@ -17,5 +17,9 @@ export interface ToolModelProvisioningOutput { */ directory: string; max_number_of_models: number | null; + /** + * Specifies if a tool requires provisioning. If enabled and a session without provisioning is requested, it will be declined. + */ + required: boolean; } diff --git a/frontend/src/app/projects/models/create-model/create-model.component.css b/frontend/src/app/projects/models/create-model/create-model.component.css deleted file mode 100644 index 8535c6938a..0000000000 --- a/frontend/src/app/projects/models/create-model/create-model.component.css +++ /dev/null @@ -1,4 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ diff --git a/frontend/src/app/projects/models/create-model/create-model.component.ts b/frontend/src/app/projects/models/create-model/create-model.component.ts index 1716d12e92..cfdc9a9c3e 100644 --- a/frontend/src/app/projects/models/create-model/create-model.component.ts +++ b/frontend/src/app/projects/models/create-model/create-model.component.ts @@ -28,7 +28,6 @@ import { ManageT4CModelComponent } from '../model-source/t4c/manage-t4c-model/ma @Component({ selector: 'app-create-model', templateUrl: './create-model.component.html', - styleUrls: ['./create-model.component.css'], standalone: true, imports: [ MatStepper, diff --git a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-dialog.component.html b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-dialog.component.html index 10a21be6b2..f3b18bd12b 100644 --- a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-dialog.component.html +++ b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-dialog.component.html @@ -95,7 +95,8 @@

View diagrams

} } @else {
error
Diagram export has failed.
diff --git a/frontend/src/app/projects/models/service/model.service.ts b/frontend/src/app/projects/models/service/model.service.ts index a32367563b..c7d3cfe91c 100644 --- a/frontend/src/app/projects/models/service/model.service.ts +++ b/frontend/src/app/projects/models/service/model.service.ts @@ -14,6 +14,7 @@ import { PatchToolModel, PostToolModel, ProjectsModelsService, + SimpleToolModelWithoutProject, ToolModel, } from 'src/app/openapi'; import { GetGitModel } from 'src/app/projects/project-detail/model-overview/model-detail/git-model.service'; @@ -157,6 +158,8 @@ export class ModelWrapperService { } } -export function getPrimaryGitModel(model: ToolModel): GetGitModel | undefined { +export function getPrimaryGitModel( + model: SimpleToolModelWithoutProject, +): GetGitModel | undefined { return model.git_models?.find((gitModel) => gitModel.primary); } diff --git a/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.html b/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.html new file mode 100644 index 0000000000..9796609c72 --- /dev/null +++ b/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.html @@ -0,0 +1,56 @@ + + +
+ @if (projectWrapperService.project$ | async) { +
+
+
+ + Tool + + @for (tool of availableTools$ | async; track tool.id) { + + {{ tool.name }} + + } + + @if (form.controls.tool_id.errors?.min) { + You have to select a tool + } + +
+
+ + Tool Version + + @for (tool of availableToolVersions$ | async; track tool.id) { + + {{ tool.name }} + + } + + @if (form.controls.tool_id.errors?.min) { + You have to select a tool + } + +
+
+ +
+
+
+ } +
diff --git a/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.ts b/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.ts new file mode 100644 index 0000000000..712bc97078 --- /dev/null +++ b/frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.ts @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, filter, switchMap, take } from 'rxjs'; +import { + PostProjectToolRequest, + ProjectsToolsService, + Tool, + ToolsService, + ToolVersion, +} from 'src/app/openapi'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; + +@Component({ + selector: 'app-create-project-tools', + standalone: true, + imports: [ + CommonModule, + MatFormFieldModule, + ReactiveFormsModule, + MatSelectModule, + MatIconModule, + MatButtonModule, + ], + templateUrl: './create-project-tools.component.html', + styles: ` + :host { + display: block; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateProjectToolsComponent implements OnInit { + form = new FormGroup({ + tool_id: new FormControl(-1, [Validators.min(0)]), + tool_version_id: new FormControl(-1, [Validators.min(0)]), + }); + + private readonly availableTools = new BehaviorSubject( + undefined, + ); + availableTools$ = this.availableTools.asObservable(); + + private readonly availableToolVersions = new BehaviorSubject< + ToolVersion[] | undefined + >(undefined); + availableToolVersions$ = this.availableToolVersions.asObservable(); + + constructor( + public projectWrapperService: ProjectWrapperService, + private projectsToolService: ProjectsToolsService, + private toolsService: ToolsService, + private router: Router, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.loadTools(); + this.form.controls.tool_id.valueChanges.subscribe((value) => { + if (!value) return; + this.loadToolVersions(value); + }); + } + + loadTools() { + this.availableTools.next(undefined); + this.toolsService + .getTools() + .subscribe((tools) => this.availableTools.next(tools)); + } + + loadToolVersions(toolID: number) { + this.availableToolVersions.next(undefined); + this.toolsService + .getToolVersions(toolID) + .subscribe((versions) => this.availableToolVersions.next(versions)); + } + + onSubmit() { + this.projectWrapperService.project$ + .pipe( + take(1), + filter(Boolean), + switchMap((project) => + this.projectsToolService.linkToolToProject( + project.slug, + this.form.value as PostProjectToolRequest, + ), + ), + ) + .subscribe(() => + this.router.navigate(['../..'], { + relativeTo: this.route, + }), + ); + } +} diff --git a/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.html b/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.html index d6d7800ce9..ff229af7d7 100644 --- a/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.html +++ b/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.html @@ -29,7 +29,8 @@ } @else if (errorCode === "FILE_NOT_FOUND") {
open_in_new @@ -40,7 +41,8 @@
} @else {
error -
-
-

Models

- @if (projectUserService.verifyRole("manager")) { - +

Models

+ @if (projectUserService.verifyRole("manager")) { +
+ add +
+
+ @if (models !== undefined && models.length > 1) { + - } + } -
-
- @if ((modelService.models$ | async) === undefined) { - @for (card of [0, 1, 2]; track $index) { - - } + } +
+
+ @if ((modelService.models$ | async) === undefined) { + @for (card of [0, 1, 2]; track $index) { + } - @for (model of modelService.models$ | async; track model.id) { + } + @for (model of modelService.models$ | async; track model.id) { +
-
-
-
- {{ model.name }} -
- - {{ model.tool.name }} - @if (model.version) { - {{ model.version.name }} - } @else { - (Version not specified) - } - +
+
+ {{ model.name }}
+ + {{ model.tool.name }} + @if (model.version) { + {{ model.version.name }} + } @else { + (Version not specified) + } + +
-
-
-
-
Nature
- - @if (model.nature) { - {{ model.nature.name }} - } @else { - Not specified - } - -
+
+
+
+
Nature
+ + @if (model.nature) { + {{ model.nature.name }} + } @else { + Not specified + } +
-
-
-
Working mode
-
- {{ getPrimaryWorkingMode(model) }} -
+
+
+
+
Working mode
+
+ {{ getPrimaryWorkingMode(model) }}
-
-
- {{ model.description || "This model has no description." }} -
-
- @if (model.tool.name === "Capella") { - - } -
-
- @if (userService.user?.role === "administrator") { - - key - - } - @if (projectUserService.verifyRole("manager")) { - - settings - - +
+
+
+ {{ model.description || "This model has no description." }} +
+
+ @if (model.tool.name === "Capella") { + + } +
+
+ @if (userService.user?.role === "administrator") { + + key + + } + @if (projectUserService.verifyRole("manager")) { + + settings + + + + link + + @if (!project?.is_archived && project?.type !== "training") { - link + sync - @if (!project?.is_archived && project?.type !== "training") { - - sync - - } } + } - @if (model.git_models) { - + open_in_new + + @if (model.tool.name === "Capella") { + - } + image_search + } + } - @if ( - !project?.is_archived && - project?.type !== "training" && - model.t4c_models && - projectUserService.verifyPermission("write") - ) { - - screen_share - - } -
+ @if ( + !project?.is_archived && + project?.type !== "training" && + model.t4c_models && + projectUserService.verifyPermission("write") + ) { + + screen_share + + }
- } -
+
+ }
diff --git a/frontend/src/app/projects/project-detail/model-overview/model-overview.component.ts b/frontend/src/app/projects/project-detail/model-overview/model-overview.component.ts index 29e6881024..152afede00 100644 --- a/frontend/src/app/projects/project-detail/model-overview/model-overview.component.ts +++ b/frontend/src/app/projects/project-detail/model-overview/model-overview.component.ts @@ -36,7 +36,6 @@ import { ModelComplexityBadgeComponent } from './model-complexity-badge/model-co @Component({ selector: 'app-model-overview', templateUrl: './model-overview.component.html', - styleUrls: ['./model-overview.component.css'], standalone: true, imports: [ MatAnchor, diff --git a/frontend/src/app/projects/project-detail/project-details.component.html b/frontend/src/app/projects/project-detail/project-details.component.html index 9ff2654a66..8f35e8c939 100644 --- a/frontend/src/app/projects/project-detail/project-details.component.html +++ b/frontend/src/app/projects/project-detail/project-details.component.html @@ -4,12 +4,23 @@ -->
-
+
- + @if ((projectService.project$ | async)?.type !== "training") { + + } +
- + + @if ((projectService.project$ | async)?.type === "training") { + + } @else { + + } +
@if (projectUserService.verifyRole("manager")) { diff --git a/frontend/src/app/projects/project-detail/project-details.component.ts b/frontend/src/app/projects/project-detail/project-details.component.ts index e2c74ac5a6..cc9b6aa0f9 100644 --- a/frontend/src/app/projects/project-detail/project-details.component.ts +++ b/frontend/src/app/projects/project-detail/project-details.component.ts @@ -2,9 +2,14 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { Component } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { ProjectToolsWrapperService } from 'src/app/projects/project-detail/project-tools/project-tools-wrapper.service'; +import { ProjectToolsComponent } from 'src/app/projects/project-detail/project-tools/project-tools.component'; import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service'; -import { CreateReadonlySessionComponent } from '../../sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component'; +import { TrainingDetailsComponent } from 'src/app/projects/project-detail/training-details/training-details.component'; +import { CreateReadonlySessionComponent } from '../../sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component'; +import { CreateProvisionedSessionComponent } from '../../sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component'; import { ProjectWrapperService } from '../service/project.service'; import { ModelOverviewComponent } from './model-overview/model-overview.component'; import { ProjectMetadataComponent } from './project-metadata/project-metadata.component'; @@ -19,11 +24,20 @@ import { ProjectUserSettingsComponent } from './project-users/project-user-setti CreateReadonlySessionComponent, ModelOverviewComponent, ProjectUserSettingsComponent, + AsyncPipe, + CreateProvisionedSessionComponent, + TrainingDetailsComponent, + ProjectToolsComponent, ], }) -export class ProjectDetailsComponent { +export class ProjectDetailsComponent implements OnInit { constructor( public projectService: ProjectWrapperService, public projectUserService: ProjectUserService, + private projectToolsWrapperService: ProjectToolsWrapperService, ) {} + + ngOnInit(): void { + this.projectToolsWrapperService.loadProjectTools(); + } } diff --git a/frontend/src/app/projects/project-detail/project-details.stories.ts b/frontend/src/app/projects/project-detail/project-details.stories.ts index ec8812c9a4..9ab24e775e 100644 --- a/frontend/src/app/projects/project-detail/project-details.stories.ts +++ b/frontend/src/app/projects/project-detail/project-details.stories.ts @@ -4,6 +4,8 @@ */ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; +import { mockProject, MockProjectWrapperService } from 'src/storybook/project'; import { MockProjectUserService } from 'src/storybook/project-users'; import { ProjectDetailsComponent } from './project-details.component'; @@ -35,3 +37,36 @@ export const LoadingAsProjectLead: Story = { }), ], }; + +export const ProjectLoaded: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: ProjectWrapperService, + useFactory: () => + new MockProjectWrapperService(mockProject, undefined), + }, + ], + }), + ], +}; + +export const TrainingLoaded: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: ProjectWrapperService, + useFactory: () => + new MockProjectWrapperService( + { ...mockProject, type: 'training' }, + undefined, + ), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/projects/project-detail/project-tools/project-tools-wrapper.service.ts b/frontend/src/app/projects/project-detail/project-tools/project-tools-wrapper.service.ts new file mode 100644 index 0000000000..99ec77cb3d --- /dev/null +++ b/frontend/src/app/projects/project-detail/project-tools/project-tools-wrapper.service.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Injectable } from '@angular/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { BehaviorSubject, filter, switchMap, take, tap } from 'rxjs'; +import { ProjectsToolsService, ProjectTool } from 'src/app/openapi'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; + +@UntilDestroy() +@Injectable({ + providedIn: 'root', +}) +export class ProjectToolsWrapperService { + constructor( + public projectWrapperService: ProjectWrapperService, + private projectToolService: ProjectsToolsService, + ) {} + + private readonly _projectTools = new BehaviorSubject< + ProjectTool[] | undefined + >(undefined); + readonly projectTools$ = this._projectTools.asObservable(); + + loadProjectTools(): void { + this._projectTools.next(undefined); + this.projectWrapperService.project$ + .pipe( + filter(Boolean), + switchMap((project) => { + return this.projectToolService.getProjectTools(project.slug); + }), + untilDestroyed(this), + take(1), + tap((tools) => { + this._projectTools.next(tools); + }), + ) + .subscribe(); + } +} diff --git a/frontend/src/app/projects/project-detail/project-tools/project-tools.component.html b/frontend/src/app/projects/project-detail/project-tools/project-tools.component.html new file mode 100644 index 0000000000..44f0b1fbeb --- /dev/null +++ b/frontend/src/app/projects/project-detail/project-tools/project-tools.component.html @@ -0,0 +1,104 @@ + + +
+

Used Tools

+ @if (projectUserService.verifyRole("manager")) { + help + + } +
+ +
+ @if ((projectToolsWrapperService.projectTools$ | async) === undefined) { +
+ @for (card of [0, 1]; track card) { + + } +
+ } @else { + + @for ( + tool of projectToolsWrapperService.projectTools$ | async; + track tool.id + ) { + + + + + + } @empty { + The project doesn't use any tools yet. + } + + } +
diff --git a/frontend/src/app/projects/project-detail/project-tools/project-tools.component.ts b/frontend/src/app/projects/project-detail/project-tools/project-tools.component.ts new file mode 100644 index 0000000000..39219fd5c2 --- /dev/null +++ b/frontend/src/app/projects/project-detail/project-tools/project-tools.component.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { filter, switchMap, take, tap } from 'rxjs'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { ProjectsToolsService, ProjectTool } from 'src/app/openapi'; +import { ProjectToolsWrapperService } from 'src/app/projects/project-detail/project-tools/project-tools-wrapper.service'; +import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; + +@UntilDestroy() +@Component({ + selector: 'app-project-tools', + standalone: true, + imports: [ + CommonModule, + RouterLink, + MatTooltipModule, + MatButtonModule, + MatIconModule, + NgxSkeletonLoaderModule, + ], + templateUrl: './project-tools.component.html', + styles: ` + :host { + display: block; + } + `, +}) +export class ProjectToolsComponent { + constructor( + public projectUserService: ProjectUserService, + public projectWrapperService: ProjectWrapperService, + public projectToolsWrapperService: ProjectToolsWrapperService, + private projectToolService: ProjectsToolsService, + private toastService: ToastService, + ) {} + + unlinkTool(tool: ProjectTool): void { + const tool_id = tool.id; + if (!tool_id) return; + + this.projectWrapperService.project$ + .pipe( + filter(Boolean), + take(1), + switchMap((project) => + this.projectToolService.deleteToolFromProject(project.slug, tool_id), + ), + tap(() => { + this.projectToolsWrapperService.loadProjectTools(); + this.toastService.showSuccess( + 'Tool unlinked from project', + `The tool ${tool.tool.name} ${tool.tool_version.name} was successfully unlinked from the project.`, + ); + }), + ) + .subscribe(); + } +} diff --git a/frontend/src/app/projects/project-detail/project-tools/project-tools.stories.ts b/frontend/src/app/projects/project-detail/project-tools/project-tools.stories.ts new file mode 100644 index 0000000000..c51b1cd5ec --- /dev/null +++ b/frontend/src/app/projects/project-detail/project-tools/project-tools.stories.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; +import { mockProject, MockProjectWrapperService } from 'src/storybook/project'; +import { + mockProjectTool, + projectToolServiceProvider, +} from 'src/storybook/project-tools'; +import { MockProjectUserService } from 'src/storybook/project-users'; +import { mockCapellaTool, mockCapellaToolVersion } from 'src/storybook/tool'; +import { ProjectToolsComponent } from './project-tools.component'; + +const meta: Meta = { + title: 'Project Components/Used Tools', + component: ProjectToolsComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: {}, +}; + +export const LoadingAsProjectLead: Story = { + decorators: [ + moduleMetadata({ + providers: [ + { + provide: ProjectUserService, + useValue: new MockProjectUserService('manager', undefined, undefined), + }, + ], + }), + ], +}; + +export const ArchivedProject: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: ProjectWrapperService, + useValue: new MockProjectWrapperService( + { ...mockProject, is_archived: true }, + undefined, + ), + }, + { + provide: ProjectUserService, + useValue: new MockProjectUserService('manager', undefined, undefined), + }, + ], + }), + ], +}; + +const projectTools = [ + mockProjectTool, + { + id: 1, + tool: { ...mockCapellaTool, name: 'Example Tool' }, + tool_version: { ...mockCapellaToolVersion, name: '1.0.0' }, + used_by: [], + }, + { + id: null, + tool: { ...mockCapellaTool, name: 'Tool 3' }, + tool_version: { ...mockCapellaToolVersion, name: 'Latest' }, + used_by: [], + }, +]; + +export const Loaded: Story = { + decorators: [ + moduleMetadata({ + providers: [projectToolServiceProvider(projectTools)], + }), + ], +}; + +export const LoadedAsProjectLead: Story = { + decorators: [ + moduleMetadata({ + providers: [ + projectToolServiceProvider(projectTools), + { + provide: ProjectUserService, + useValue: new MockProjectUserService('manager', undefined, undefined), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/projects/project-detail/training-details/training-details.component.html b/frontend/src/app/projects/project-detail/training-details/training-details.component.html new file mode 100644 index 0000000000..b6ffbbaa66 --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.component.html @@ -0,0 +1,119 @@ + + +
+
+

Training Details

+ @if ( + projectUserService.verifyRole("manager") && + (modelService.models$ | async)?.length === 0 + ) { +
+ add +
+
+ } +
+
+ @if ((modelService.models$ | async) === undefined) { + + } @else { + @for (model of modelService.models$ | async; track model.id) { +
+
+
+ {{ model.tool.name }} ({{ model.version?.name }}) +
+ @if (getPrimaryGitModel(model); as gitModel) { +
+ Revision {{ gitModel.revision }} +
+ } +
+ +
+
+ +
+
+ @if (projectUserService.verifyRole("manager")) { + + settings + + + link + + } + @if (model.git_models) { + + open_in_new + + } +
+
+
+ } @empty { +
+ The training is not configured yet. +
+ } + } +
+
diff --git a/frontend/src/app/projects/project-detail/training-details/training-details.component.ts b/frontend/src/app/projects/project-detail/training-details/training-details.component.ts new file mode 100644 index 0000000000..3a9db11463 --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.component.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { AsyncPipe, CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { + MatAnchor, + MatButton, + MatMiniFabAnchor, + MatMiniFabButton, +} from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { MarkdownComponent, provideMarkdown } from 'ngx-markdown'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { combineLatest } from 'rxjs'; +import { SKIP_ERROR_HANDLING_CONTEXT } from 'src/app/general/error-handling/error-handling.interceptor'; +import { ProjectsModelsREADMEService, ToolModel } from 'src/app/openapi'; +import { + getPrimaryGitModel, + ModelWrapperService, +} from 'src/app/projects/models/service/model.service'; +import { GetGitModel } from 'src/app/projects/project-detail/model-overview/model-detail/git-model.service'; +import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; + +@UntilDestroy() +@Component({ + selector: 'app-training-details', + standalone: true, + imports: [ + CommonModule, + MatAnchor, + RouterLink, + MatTooltip, + MatIcon, + MatButton, + NgxSkeletonLoaderModule, + MatMiniFabAnchor, + MatMiniFabButton, + AsyncPipe, + MarkdownComponent, + ], + templateUrl: './training-details.component.html', + styles: ` + :host { + display: block; + } + `, + providers: [provideMarkdown()], +}) +export class TrainingDetailsComponent implements OnInit { + constructor( + public modelService: ModelWrapperService, + public projectUserService: ProjectUserService, + public projectService: ProjectWrapperService, + private readmeService: ProjectsModelsREADMEService, + ) {} + + getPrimaryGitModelURL(model: ToolModel): string { + const primaryModel = getPrimaryGitModel(model); + return primaryModel ? primaryModel.path : ''; + } + + getPrimaryGitModel(model: ToolModel): GetGitModel | undefined { + return getPrimaryGitModel(model); + } + + readmes = new Map(); + + ngOnInit(): void { + combineLatest([this.projectService.project$, this.modelService.models$]) + .pipe(untilDestroyed(this)) + .subscribe(([project, models]) => { + if (!models || !project) return; + if (project.type === 'general') return; + for (const model of models) { + this.readmeService + .getReadme(project.slug, model.slug, 'body', false, { + httpHeaderAccept: 'text/markdown', + context: SKIP_ERROR_HANDLING_CONTEXT, + }) + .subscribe((readme) => { + this.readmes.set(model.slug, readme); + }); + } + }); + } +} diff --git a/frontend/src/app/projects/project-detail/training-details/training-details.stories.ts b/frontend/src/app/projects/project-detail/training-details/training-details.stories.ts new file mode 100644 index 0000000000..488b9377a0 --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.stories.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { + mockModel, + mockModelWrapperServiceProvider, +} from 'src/storybook/model'; +import { mockProjectUserServiceProvider } from 'src/storybook/project-users'; +import { TrainingDetailsComponent } from './training-details.component'; + +const meta: Meta = { + title: 'Project Components/Training Details', + component: TrainingDetailsComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = {}; + +export const NoModel: Story = { + decorators: [ + moduleMetadata({ + providers: [mockModelWrapperServiceProvider(undefined, [])], + }), + ], +}; + +export const NoModelAsProjectLead: Story = { + decorators: [ + moduleMetadata({ + providers: [ + mockProjectUserServiceProvider('manager'), + mockModelWrapperServiceProvider(undefined, []), + ], + }), + ], +}; + +const readme = ` +# Heading level 1 +## Heading level 2 +### Heading level 3 + +This is an example of **bold text**. +And another example with *italics*. + +- list + - sublist + - test +- item 2 + +1. Ordered list +2. Test + +This is a example text with a [link to an external page](https://example.com). + +You can also use code blocks: + +${'```'}zsh +git clone --recurse-submodules https://github.com/DSD-DBS/capella-collab-manager.git +cd capella-collab-manager +${'```'} +`; + +export const Loaded: Story = { + args: { + readmes: new Map([[mockModel.slug, readme]]), + }, + decorators: [ + moduleMetadata({ + providers: [mockModelWrapperServiceProvider(undefined, [mockModel])], + }), + ], +}; + +export const LoadedAsProjectLead: Story = { + args: { + readmes: new Map([[mockModel.slug, readme]]), + }, + decorators: [ + moduleMetadata({ + providers: [ + mockProjectUserServiceProvider('manager'), + mockModelWrapperServiceProvider(undefined, [mockModel]), + ], + }), + ], +}; diff --git a/frontend/src/app/projects/project-overview/project-overview.component.html b/frontend/src/app/projects/project-overview/project-overview.component.html index f96e999930..23fce40423 100644 --- a/frontend/src/app/projects/project-overview/project-overview.component.html +++ b/frontend/src/app/projects/project-overview/project-overview.component.html @@ -19,14 +19,26 @@
Projects - Trainings + +
+ schoolTrainings +
+
- Internal - Private + +
+ lock_openInternal +
+
+ +
+ lockPrivate +
+
@@ -56,9 +68,14 @@ matRipple class="mat-card-overview collab-card !m-0" > -
+
@if (project.type === "training") { - school + school + } + @if (project.visibility === "private") { + lock + } @else { + lock_open } {{ project.name }} @@ -73,11 +90,8 @@
- {{ - project.visibility === "internal" - ? "Internal project" - : "Private project" - }} + {{ project.visibility === "internal" ? "Internal" : "Private" }} + {{ project.type === "training" ? "training" : "project" }}
{{ project.users.leads }} project admin(s), diff --git a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html index b7c75bde8d..4c833dc2a7 100644 --- a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html +++ b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html @@ -21,15 +21,13 @@

[attr.data-testid]="'rating-' + rating" mat-icon-button type="button" - [class]=" - [ - '!flex', - getColorForRating(rating), - this.feedbackForm.get('rating')?.value === rating - ? '!bg-gray-200' - : '', - ].join(' ') - " + [ngClass]="[ + '!flex', + getColorForRating(rating), + this.feedbackForm.get('rating')?.value === rating + ? '!bg-gray-200' + : '', + ]" > @switch (rating) { diff --git a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.ts b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.ts index 7748f0815d..68c2d39dba 100644 --- a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.ts +++ b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { AsyncPipe } from '@angular/common'; +import { AsyncPipe, NgClass } from '@angular/common'; import { Component, Inject } from '@angular/core'; import { FormControl, @@ -55,6 +55,7 @@ interface DialogData { ReactiveFormsModule, FormsModule, AsyncPipe, + NgClass, ], templateUrl: './feedback-dialog.component.html', }) diff --git a/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html index a3cab34782..7f954cf29f 100644 --- a/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html +++ b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html @@ -18,7 +18,8 @@ (cdkDragEnded)="dragStop()" >
diff --git a/frontend/src/app/sessions/sessions.component.html b/frontend/src/app/sessions/sessions.component.html index fbe63fc1e9..7460f08f83 100644 --- a/frontend/src/app/sessions/sessions.component.html +++ b/frontend/src/app/sessions/sessions.component.html @@ -8,12 +8,9 @@
- - + + +
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html index a3ee5ecffa..6a3c684ec7 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html @@ -25,7 +25,7 @@

No active sessions

} @else if ((userSessionService.sessions$ | async)?.length !== 0) {
= { ], }), componentWrapperDecorator( - (story) => `
${story}
`, + (story) => `
${story}
`, ), ], }; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts index 7f05b8fc35..b677b17411 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts @@ -7,7 +7,7 @@ import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; import { dialogWrapper } from 'src/storybook/decorators'; import { mockPersistentSession } from 'src/storybook/session'; -import { mockTool } from 'src/storybook/tool'; +import { mockCapellaTool } from 'src/storybook/tool'; import { MockOwnUserWrapperService, mockUser } from 'src/storybook/user'; import { ConnectionDialogComponent } from './connection-dialog.component'; @@ -47,7 +47,7 @@ export const WithoutTeamForCapella: Story = { ...mockPersistentSession, version: { ...mockPersistentSession.version, - tool: { ...mockTool, integrations: { t4c: false } }, + tool: { ...mockCapellaTool, integrations: { t4c: false } }, }, }, }, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.html similarity index 71% rename from frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.html rename to frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.html index 4b1e0b6269..f7c4f1f142 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.html @@ -15,21 +15,26 @@

Read-only Sessions

[formGroup]="toolSelectionForm" (ngSubmit)="requestReadonlySession()" > -
-
- + @if (tools === undefined) { +
+ @for (menu of [0, 1]; track menu) { +
+ +
+ }
-
+ } +
@@ -50,12 +55,11 @@

Read-only Sessions

Version - - {{ version.name }} - + @for (version of this.relevantToolVersions; track version.id) { + + {{ version.name }} + + }
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.ts similarity index 93% rename from frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.ts rename to frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.ts index a9b9b6e51d..bc3d9206dc 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.component.ts @@ -19,7 +19,7 @@ import { MatSelect } from '@angular/material/select'; import { RouterLink } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { combineLatest, filter, Observable, tap } from 'rxjs'; +import { combineLatest, filter, Observable } from 'rxjs'; import { Tool, ToolModel, ToolVersion } from 'src/app/openapi'; import { ModelWrapperService } from 'src/app/projects/models/service/model.service'; import { ProjectWrapperService } from 'src/app/projects/service/project.service'; @@ -28,13 +28,12 @@ import { ToolWrapperService, ToolVersionWithTool, } from 'src/app/settings/core/tools-settings/tool.service'; -import { CreateReadonlySessionDialogComponent } from '../../create-sessions/create-readonly-session/create-readonly-session-dialog.component'; +import { CreateReadonlySessionDialogComponent } from '../create-sessions/create-readonly-session/create-readonly-session-dialog.component'; @UntilDestroy() @Component({ selector: 'app-create-readonly-session', templateUrl: './create-readonly-session.component.html', - styleUrls: ['./create-readonly-session.component.css'], standalone: true, imports: [ FormsModule, @@ -56,7 +55,6 @@ export class CreateReadonlySessionComponent implements OnInit { models?: ModelWithCompatibility[]; relevantToolVersions?: ToolVersion[]; - allToolVersions?: ToolVersionWithTool[]; public toolSelectionForm = this.fb.group({ tool: this.fb.control(null, Validators.required), @@ -91,11 +89,7 @@ export class CreateReadonlySessionComponent implements OnInit { return combineLatest([ this.modelService.models$.pipe(untilDestroyed(this), filter(Boolean)), this.toolWrapperService.getVersionsForTools(), - ]).pipe( - tap(([_, versions]) => { - this.allToolVersions = versions; - }), - ); + ]); } resolveVersionCompatibility( diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.stories.ts new file mode 100644 index 0000000000..07fca679d4 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-readonly-session/create-readonly-session.stories.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, StoryObj } from '@storybook/angular'; +import { mockModel } from 'src/storybook/model'; +import { mockCapellaToolVersion } from 'src/storybook/tool'; +import { CreateReadonlySessionComponent } from './create-readonly-session.component'; + +const meta: Meta = { + title: 'Session Components/Create Readonly Session', + component: CreateReadonlySessionComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: {}, +}; + +export const Loaded: Story = { + args: { + relevantToolVersions: [mockCapellaToolVersion], + models: [{ ...mockModel, compatibleVersions: [] }], + }, +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.css b/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.css deleted file mode 100644 index 5c672c4484..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-session/create-readonly-session/create-readonly-session.component.css +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.skeleton-loader { - gap: 10px; -} - -.skeleton-loader-element { - flex-basis: calc(50% - 5px); -} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html index 8d80805df7..b02760b71a 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html @@ -24,12 +24,11 @@

Persistent Workspace Session

formControlName="toolId" (selectionChange)="toolSelectionChange($event.value)" > - - {{ tool.name }} - + @for (tool of toolsWithWorkspaceEnabled | async; track tool.id) { + + {{ tool.name }} + + } Please select a valid tool. @@ -37,14 +36,17 @@

Persistent Workspace Session

Version - - {{ version.name }} - (recommended) - (deprecated) - + @for (version of this.versions; track version.id) { + + {{ version.name }} + @if (version.config.is_recommended) { + (recommended) + } + @if (version.config.is_deprecated) { + (deprecated) + } + + }
@@ -84,8 +86,14 @@

Persistent Workspace Session

type="submit" [disabled]="requestInProgress" > - Request a session with a persistent workspace - keyboard_arrow_right + + + Request session + keyboard_arrow_right +
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts index bd8e7dac97..3cd4dda02e 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { AsyncPipe, NgFor, NgIf } from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormControl, @@ -41,10 +41,8 @@ import { CreateSessionHistoryComponent } from '../create-session-history/create- MatFormField, MatLabel, MatSelect, - NgFor, MatOption, MatError, - NgIf, MatRadioGroup, MatRadioButton, MatButton, @@ -151,7 +149,9 @@ export class CreatePersistentSessionComponent implements OnInit { return this.toolWrapperService.tools$.pipe( map((tools) => tools?.filter( - (tool) => tool.config.persistent_workspaces.mounting_enabled, + (tool) => + tool.config.persistent_workspaces.mounting_enabled && + !tool.config.provisioning.required, ), ), ); diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts index 564fc3c7ae..daa94b7360 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts @@ -9,7 +9,7 @@ import { StoryObj, } from '@storybook/angular'; import { BehaviorSubject, Observable } from 'rxjs'; -import { mockTool } from '../../../../../storybook/tool'; +import { mockCapellaTool } from '../../../../../storybook/tool'; import { Tool } from '../../../../openapi'; import { ToolWrapperService } from '../../../../settings/core/tools-settings/tool.service'; import { MockLicenseUsageWrapperService } from '../../../license-indicator/license-indicator.stories'; @@ -22,7 +22,7 @@ const meta: Meta = { decorators: [ componentWrapperDecorator( (story) => - `
+ `
${story}
`, ), @@ -72,7 +72,7 @@ export const Default: Story = { }, { provide: ToolWrapperService, - useFactory: () => new MockToolWrapperService([mockTool]), + useFactory: () => new MockToolWrapperService([mockCapellaTool]), }, ], }), diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.html new file mode 100644 index 0000000000..c12d6a285e --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.html @@ -0,0 +1,144 @@ + + +
+
+

+ @if ((projectWrapperService.project$ | async)?.type === "training") { + Training Provisioning + } @else { + Session Provisioning + } +

+ experimentExperimental +
+ +
+
+

+ Provision sessions and a workspace for this + {{ projectDisplayName$ | async }}. The + {{ projectDisplayName$ | async }} will be provisioned once and is + persistent from then on. You'll be able to reset the workspace to the + original state at any time. + @if ((projectWrapperService.project$ | async)?.type === "general") { + Please note that TeamForCapella repositories are not supported for + automatic provisioning yet. + } +

+ +
+
+ @if (provisioningPerTool?.length) { + A provisioning will spawn the following sessions: + @for (tool of provisioningPerTool; track $index) { +
+ {{ tool.tool.name }} {{ tool.tool_version.name }} + + @for (model of tool.used_by; track model.slug) { +
+ play_circle +
+ {{ model.name }} + @if (model.provisioning; as provisioning) { + was provisioned at + {{ provisioning.provisioned_at | date }} with the + revision {{ provisioning.revision }} ({{ + provisioning.commit_hash + }}). + } @else { + will be provisioned. + } +
+
+ } @empty { +
+ cancel +
The session will be created, but not provisioned.
+
+ } +
+ } + } @else if (provisioningPerTool === undefined) { +
+ +
+ } @else { + Please add at least one tool to the project to provision a session. + } +
+
+ +
+
+ + @if (provisioningPerTool?.length) { + + } @else if (provisioningPerTool === undefined) { +
+ +
+ } +
+
+
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.stories.ts new file mode 100644 index 0000000000..2626f07dea --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.stories.ts @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { mockSimpleToolModelWithoutProject } from 'src/storybook/model'; +import { + mockProject, + mockProjectWrapperServiceProvider, +} from 'src/storybook/project'; +import { mockProjectTool, mockTrainingTool } from 'src/storybook/project-tools'; +import { CreateProvisionedSessionComponent } from './create-provisioned-session.component'; + +const meta: Meta = { + title: 'Session Components/Create Provisioned Session', + component: CreateProvisionedSessionComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: {}, +}; + +export const LoadedProjectWithoutTools: Story = { + args: { + provisioningPerTool: [], + }, + decorators: [ + moduleMetadata({ + providers: [mockProjectWrapperServiceProvider(mockProject)], + }), + ], +}; + +export const LoadedProjectWithoutModels: Story = { + args: { + provisioningPerTool: [ + { + ...mockProjectTool, + used_by: [], + }, + ], + }, + decorators: [ + moduleMetadata({ + providers: [mockProjectWrapperServiceProvider(mockProject)], + }), + ], +}; + +const toolProvisioning = { + ...mockSimpleToolModelWithoutProject, + provisioning: null, +}; + +const coffeeMachineProvisioning = { + id: 2, + slug: 'coffee-machine', + name: 'Coffee machine', + git_models: [], + provisioning: { + session: null, + provisioned_at: '2024-04-29T14:00:00Z', + revision: 'main', + commit_hash: 'db45166576e7f1e7fec3256e8657ba431f9b5b77', + }, +}; + +export const LoadedProject: Story = { + args: { + provisioningPerTool: [ + { + ...mockProjectTool, + used_by: [toolProvisioning, coffeeMachineProvisioning], + }, + ], + }, + decorators: [ + moduleMetadata({ + providers: [mockProjectWrapperServiceProvider(mockProject)], + }), + ], +}; + +export const LoadedProjectContinue: Story = { + args: { + provisioningPerTool: [ + { + ...mockProjectTool, + used_by: [coffeeMachineProvisioning], + }, + ], + }, + decorators: [ + moduleMetadata({ + providers: [mockProjectWrapperServiceProvider(mockProject)], + }), + ], +}; + +export const LoadedTraining: Story = { + args: { + provisioningPerTool: [ + { + ...mockProjectTool, + used_by: [], + }, + { + ...mockTrainingTool, + used_by: [ + { + id: 3, + slug: 'pvmt-training', + name: 'PVMT Training', + git_models: [], + provisioning: null, + }, + ], + }, + ], + }, + decorators: [ + moduleMetadata({ + providers: [ + mockProjectWrapperServiceProvider({ ...mockProject, type: 'training' }), + ], + }), + ], +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.ts new file mode 100644 index 0000000000..ead4d2cb7c --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.ts @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { CommonModule, KeyValuePipe } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { combineLatest, map, Observable, of, switchMap, take, tap } from 'rxjs'; +import { ConfirmationDialogComponent } from 'src/app/helpers/confirmation-dialog/confirmation-dialog.component'; +import { MatIconComponent } from 'src/app/helpers/mat-icon/mat-icon.component'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { + ModelProvisioning, + ProjectsModelsProvisioningService, + ProjectTool, + ProjectType, + SessionProvisioningRequest, + SessionsService, + SessionType, + SimpleToolModelWithoutProject, +} from 'src/app/openapi'; +import { getPrimaryGitModel } from 'src/app/projects/models/service/model.service'; +import { ProjectToolsWrapperService } from 'src/app/projects/project-detail/project-tools/project-tools-wrapper.service'; +import { ProjectWrapperService } from 'src/app/projects/service/project.service'; +import { SessionService } from 'src/app/sessions/service/session.service'; + +@UntilDestroy() +@Component({ + selector: 'app-create-provisioned-session', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + NgxSkeletonLoaderModule, + MatIconComponent, + KeyValuePipe, + ], + templateUrl: './create-provisioned-session.component.html', + styles: ` + :host { + display: block; + } + `, +}) +export class CreateProvisionedSessionComponent implements OnInit { + constructor( + public sessionService: SessionService, + public projectWrapperService: ProjectWrapperService, + private provisioningService: ProjectsModelsProvisioningService, + private projectToolsWrapperService: ProjectToolsWrapperService, + private sessionsService: SessionsService, + private toastService: ToastService, + private router: Router, + private dialog: MatDialog, + ) {} + + provisioningRequestInProgress = false; + + provisioningPerTool: ProjectToolWithProvisioning[] | undefined = undefined; + get provisioningRequired(): boolean { + return ( + this.provisioningPerTool?.some((tool) => + tool.used_by.some((model) => !model.provisioning), + ) ?? true + ); + } + + ngOnInit(): void { + this.loadProvisioningInfo().subscribe(); + } + + get projectDisplayName$(): Observable { + return this.projectWrapperService.project$.pipe( + map((project) => { + if (project?.type === ProjectType.Training) { + return 'training'; + } else { + return 'project'; + } + }), + ); + } + + loadProvisioningInfo() { + return combineLatest([ + this.projectWrapperService.project$, + this.projectToolsWrapperService.projectTools$, + ]).pipe( + tap(([_, tools]) => { + this.provisioningPerTool = tools as ProjectToolWithProvisioning[]; + }), + switchMap(([project, tools]) => { + if (!tools || !project) { + return of(undefined); + } + return combineLatest( + tools.map((tool) => + combineLatest( + tool.used_by.map((model) => + this.provisioningService + .getProvisioning(project.slug, model.slug) + .pipe( + tap((provisioning) => { + const foundModel = this.provisioningPerTool + ?.find( + (provisioningTool) => provisioningTool.id === tool.id, + ) + ?.used_by?.find( + (provisioningModel) => + provisioningModel.slug === model.slug, + ); + + if (foundModel) { + foundModel.provisioning = provisioning; + } + }), + ), + ), + ), + ), + ); + }), + untilDestroyed(this), + ); + } + + provisionWorkspace(): void { + this.projectWrapperService.project$.pipe(take(1)).subscribe((project) => { + this.provisioningRequestInProgress = true; + if (!this.provisioningPerTool || !project) return; + const requests = []; + for (const tool of this.provisioningPerTool) { + const provisioningRequests: SessionProvisioningRequest[] = []; + for (const model of tool.used_by) { + const primaryGitModel = getPrimaryGitModel(model); + if (!primaryGitModel) { + this.toastService.showError( + `Couldn't provision ${model.name}`, + `It has no linked Git repository`, + ); + continue; + } + provisioningRequests.push({ + project_slug: project.slug, + model_slug: model.slug, + git_model_id: primaryGitModel.id, + deep_clone: true, + }); + } + + requests.push( + this.sessionsService.requestSession({ + tool_id: tool.tool.id, + version_id: tool.tool_version.id, + session_type: SessionType.Persistent, + provisioning: provisioningRequests, + project_slug: project.slug, + }), + ); + } + combineLatest(requests).subscribe({ + next: () => { + this.provisioningRequestInProgress = false; + this.router.navigateByUrl('/'); + }, + error: () => { + this.provisioningRequestInProgress = false; + }, + }); + }); + } + + resetProvisioning() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Reset provisioning', + text: + 'Do you really want to reset the provisioning information?' + + ' This will reset the existing provisioned workspace to the latest state during the next session start.' + + ' You will lose all changes made in the workspace.', + }, + }); + dialogRef.afterClosed().subscribe((result) => { + if (!result) return; + this.projectWrapperService.project$.pipe(take(1)).subscribe((project) => { + if (!this.provisioningPerTool || !project) return; + combineLatest( + this.provisioningPerTool + .map((tool) => tool.used_by) + .flat() + .map((model) => { + if (model.provisioning) { + return this.provisioningService.resetProvisioning( + project.slug, + model.slug, + ); + } + return of(undefined); + }), + ).subscribe(() => { + this.toastService.showSuccess( + 'Provisioning reset successful', + 'The provisioning information has been cleared successfully. You can now start a new provisioning to fetch the latest data.', + ); + this.loadProvisioningInfo().pipe(take(1)).subscribe(); + }); + }); + }); + } +} + +type ModelWithProvisioning = { + provisioning: ModelProvisioning | undefined | null; +} & SimpleToolModelWithoutProject; + +type ProjectToolWithProvisioning = Omit & { + used_by: ModelWithProvisioning[]; +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog.stories.ts index e408602d72..742bfe52a8 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog.stories.ts @@ -9,7 +9,7 @@ import { SessionService } from 'src/app/sessions/service/session.service'; import { dialogWrapper } from 'src/storybook/decorators'; import { mockPrimaryGitModel } from 'src/storybook/git'; import { createModelWithId } from 'src/storybook/model'; -import { mockTool, mockToolVersion } from 'src/storybook/tool'; +import { mockCapellaTool, mockCapellaToolVersion } from 'src/storybook/tool'; import { CreateReadonlySessionDialogComponent } from './create-readonly-session-dialog.component'; class MockSessionService implements Partial {} @@ -25,12 +25,12 @@ const meta: Meta = { ], }; -const tool: Tool = { ...mockTool }; +const tool: Tool = { ...mockCapellaTool }; tool.config.provisioning.max_number_of_models = 1; const data = { tool: tool, - toolVersion: mockToolVersion, + toolVersion: mockCapellaToolVersion, models: [], projectSlug: '', }; @@ -123,7 +123,11 @@ export const ShowNoteForCompatibleSession: Story = { ], data: { tool: { ...tool, id: 2, name: 'compatibleTool' }, - toolVersion: { ...mockToolVersion, id: 2, name: 'compatibleVersion' }, + toolVersion: { + ...mockCapellaToolVersion, + id: 2, + name: 'compatibleVersion', + }, models: [], projectSlug: '', }, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html index 4f4e1d6373..85011069ed 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html @@ -17,7 +17,8 @@ } @for (session of sortedResolvedHistory; track $index) {