From a58bff879444e004d79872fcc2d62188f9ea22dd Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Fri, 11 Oct 2024 17:58:31 +0200 Subject: [PATCH] feat: Add support for provisioning of tool models The already known provisioning feature for read-only sessions will be extended to persistent workspace sessions. Per session, a user can request a persistent workspace session and can pass optionally one model to provision. It's not possible to provision more than one model (aka one Git repository). The provisioning takes place once. We'll not touch the provisioned workspace until the user explicently resets the provisioning. The provisioning revision and date are stored in the database. When the users reset their workspace, we'll remove the provisioning model from the database. During the next session start, the matching workspace will be re-initialized and a copy will be saved to a `.bak` directory. This feature is essential for the start-up of trainings. --- .../014438261702_add_provisioning_feature.py | 51 + .../2f8449c217fa_add_project_tools_table.py | 36 + backend/capellacollab/core/database/models.py | 2 + backend/capellacollab/core/responses.py | 30 + backend/capellacollab/projects/models.py | 6 + backend/capellacollab/projects/routes.py | 6 + .../toolmodels/backups/runs/interface.py | 2 + .../capellacollab/projects/toolmodels/crud.py | 8 +- .../projects/toolmodels/models.py | 16 + .../toolmodels/modelsources/git/routes.py | 6 +- .../toolmodels/provisioning/__init__.py | 2 + .../projects/toolmodels/provisioning/crud.py | 37 + .../toolmodels/provisioning/exceptions.py | 16 + .../toolmodels/provisioning/injectables.py | 26 + .../toolmodels/provisioning/models.py | 66 + .../toolmodels/provisioning/routes.py | 60 + .../projects/toolmodels/readme/routes.py | 40 + .../projects/toolmodels/routes.py | 12 + .../capellacollab/projects/tools/__init__.py | 2 + backend/capellacollab/projects/tools/crud.py | 56 + .../projects/tools/exceptions.py | 36 + .../projects/tools/injectables.py | 28 + .../capellacollab/projects/tools/models.py | 64 + .../capellacollab/projects/tools/routes.py | 117 ++ backend/capellacollab/sessions/exceptions.py | 36 + .../capellacollab/sessions/hooks/__init__.py | 2 + .../capellacollab/sessions/hooks/interface.py | 13 + .../sessions/hooks/project_scope.py | 44 + .../sessions/hooks/provisioning.py | 237 ++- backend/capellacollab/sessions/idletimeout.py | 2 + backend/capellacollab/sessions/models.py | 33 +- .../capellacollab/sessions/operators/k8s.py | 3 +- backend/capellacollab/sessions/routes.py | 46 +- backend/capellacollab/sessions/util.py | 22 + .../settings/modelsources/git/core.py | 39 +- .../settings/modelsources/git/exceptions.py | 10 + .../settings/modelsources/git/routes.py | 4 +- backend/capellacollab/tools/models.py | 7 + backend/pyproject.toml | 1 + backend/tests/core/test_auth_injectables.py | 7 - backend/tests/projects/test_projects_tools.py | 119 ++ .../toolmodels/provisioning/fixtures.py | 35 + .../provisioning/test_provisioning.py | 88 + backend/tests/sessions/hooks/conftest.py | 3 +- .../sessions/hooks/test_project_scope.py | 23 + .../sessions/hooks/test_provisioning_hook.py | 231 ++- .../sessions/test_session_environment.py | 19 +- backend/tests/sessions/test_session_hooks.py | 13 +- backend/tests/sessions/test_session_routes.py | 59 + backend/tests/settings/fixtures.py | 33 + backend/tests/settings/test_git_instances.py | 22 +- docs/docs/admin/tools/configuration.md | 7 + docs/docs/user/sessions/types/index.md | 53 +- frontend/package-lock.json | 1516 ++++++++++++++++- frontend/package.json | 1 + frontend/src/app/app-routing.module.ts | 11 + .../src/app/openapi/.openapi-generator/FILES | 7 + frontend/src/app/openapi/api/api.ts | 8 +- .../projects-models-provisioning.service.ts | 248 +++ .../api/projects-models-readme.service.ts | 171 ++ .../app/openapi/api/projects-tools.service.ts | 330 ++++ .../app/openapi/model/model-provisioning.ts | 21 + frontend/src/app/openapi/model/models.ts | 4 + .../model/post-project-tool-request.ts | 18 + .../app/openapi/model/post-session-request.ts | 6 +- .../src/app/openapi/model/project-tool.ts | 23 + .../model/session-provisioning-request.ts | 2 +- .../simple-tool-model-without-project.ts | 21 + .../model/tool-model-provisioning-input.ts | 4 + .../model/tool-model-provisioning-output.ts | 4 + .../projects/models/service/model.service.ts | 5 +- .../create-project-tools.component.html | 56 + .../create-project-tools.component.ts | 111 ++ .../model-complexity-badge.component.ts | 5 +- .../project-details.component.html | 16 +- .../project-details.component.ts | 20 +- .../project-detail/project-details.stories.ts | 56 +- .../project-tools-wrapper.service.ts | 42 + .../project-tools.component.html | 104 ++ .../project-tools/project-tools.component.ts | 69 + .../project-tools/project-tools.stories.ts | 88 + .../training-details.component.html | 146 ++ .../training-details.component.ts | 107 ++ .../training-details.stories.ts | 121 ++ .../project-overview.component.html | 34 +- .../create-persistent-session.component.ts | 8 +- .../create-provisioned-session.component.html | 144 ++ ...e-provisioned-session.component.stories.ts | 131 ++ .../create-provisioned-session.component.ts | 224 +++ frontend/src/storybook/model.ts | 13 +- frontend/src/storybook/project-tools.ts | 48 + frontend/src/storybook/tool.ts | 18 + .../session-preparation/clone_repositories.py | 42 +- 93 files changed, 5734 insertions(+), 205 deletions(-) create mode 100644 backend/capellacollab/alembic/versions/014438261702_add_provisioning_feature.py create mode 100644 backend/capellacollab/alembic/versions/2f8449c217fa_add_project_tools_table.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/__init__.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/crud.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/exceptions.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/injectables.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/models.py create mode 100644 backend/capellacollab/projects/toolmodels/provisioning/routes.py create mode 100644 backend/capellacollab/projects/toolmodels/readme/routes.py create mode 100644 backend/capellacollab/projects/tools/__init__.py create mode 100644 backend/capellacollab/projects/tools/crud.py create mode 100644 backend/capellacollab/projects/tools/exceptions.py create mode 100644 backend/capellacollab/projects/tools/injectables.py create mode 100644 backend/capellacollab/projects/tools/models.py create mode 100644 backend/capellacollab/projects/tools/routes.py create mode 100644 backend/capellacollab/sessions/hooks/project_scope.py create mode 100644 backend/tests/projects/test_projects_tools.py create mode 100644 backend/tests/projects/toolmodels/provisioning/fixtures.py create mode 100644 backend/tests/projects/toolmodels/provisioning/test_provisioning.py create mode 100644 backend/tests/sessions/hooks/test_project_scope.py create mode 100644 backend/tests/settings/fixtures.py create mode 100644 frontend/src/app/openapi/api/projects-models-provisioning.service.ts create mode 100644 frontend/src/app/openapi/api/projects-models-readme.service.ts create mode 100644 frontend/src/app/openapi/api/projects-tools.service.ts create mode 100644 frontend/src/app/openapi/model/model-provisioning.ts create mode 100644 frontend/src/app/openapi/model/post-project-tool-request.ts create mode 100644 frontend/src/app/openapi/model/project-tool.ts create mode 100644 frontend/src/app/openapi/model/simple-tool-model-without-project.ts create mode 100644 frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.html create mode 100644 frontend/src/app/projects/project-detail/create-project-tools/create-project-tools.component.ts create mode 100644 frontend/src/app/projects/project-detail/project-tools/project-tools-wrapper.service.ts create mode 100644 frontend/src/app/projects/project-detail/project-tools/project-tools.component.html create mode 100644 frontend/src/app/projects/project-detail/project-tools/project-tools.component.ts create mode 100644 frontend/src/app/projects/project-detail/project-tools/project-tools.stories.ts create mode 100644 frontend/src/app/projects/project-detail/training-details/training-details.component.html create mode 100644 frontend/src/app/projects/project-detail/training-details/training-details.component.ts create mode 100644 frontend/src/app/projects/project-detail/training-details/training-details.stories.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.html create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.stories.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component.ts create mode 100644 frontend/src/storybook/project-tools.ts 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/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/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 18d5719a92..6f7325c5b0 100644 --- a/backend/capellacollab/sessions/hooks/__init__.py +++ b/backend/capellacollab/sessions/hooks/__init__.py @@ -12,6 +12,7 @@ log_collector, networking, persistent_workspace, + project_scope, provisioning, pure_variants, read_only_workspace, @@ -30,6 +31,7 @@ guacamole.GuacamoleIntegration(), http.HTTPIntegration(), read_only_workspace.ReadOnlyWorkspaceHook(), + project_scope.ProjectScopeHook(), provisioning.ProvisionWorkspaceHook(), session_preparation.GitRepositoryCloningHook(), networking.NetworkingIntegration(), diff --git a/backend/capellacollab/sessions/hooks/interface.py b/backend/capellacollab/sessions/hooks/interface.py index b06f59ca3b..a60d309395 100644 --- a/backend/capellacollab/sessions/hooks/interface.py +++ b/backend/capellacollab/sessions/hooks/interface.py @@ -9,6 +9,7 @@ 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 @@ -52,6 +53,7 @@ class ConfigurationHookRequest: session_type: sessions_models.SessionType connection_method: tools_models.ToolSessionConnectionMethod provisioning: list[sessions_models.SessionProvisioningRequest] + project_scope: projects_models.DatabaseProject | None session_id: str @@ -226,6 +228,17 @@ def configuration_hook( 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, 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 9174c52f9f..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,20 +51,19 @@ class ProvisionWorkspaceHook(interface.HookRegistration): """Takes care of the provisioning of user workspaces.""" @classmethod - def configuration_hook( - cls, request: interface.ConfigurationHookRequest + async def async_configuration_hook( + cls, + request: interface.ConfigurationHookRequest, ) -> interface.ConfigurationHookResult: - 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 + if len(request.provisioning) == 0: + if request.tool.config.provisioning.required: + raise sessions_exceptions.ProvisioningRequiredError() + return interface.ConfigurationHookResult( + environment={"CAPELLACOLLAB_SESSION_PROVISIONING": []} ) + cls._verify_max_number_of_models(request) + resolved_entries = cls._resolve_provisioning_request( request.db, request.provisioning ) @@ -64,20 +74,132 @@ def configuration_hook( request.db, request.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 @@ -107,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, @@ -127,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, @@ -146,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 @@ -165,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.""" @@ -181,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 @@ -190,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/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/models.py b/backend/capellacollab/sessions/models.py index bf9f36f387..86813392fe 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -24,6 +24,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 @@ -52,7 +55,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 @@ -61,10 +64,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): @@ -190,6 +205,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 a10a390343..a6527d0d70 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 from kubernetes import client from kubernetes.client import exceptions @@ -51,7 +52,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 9f12be111e..6ed27b24de 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -17,7 +17,9 @@ 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 @@ -83,7 +85,7 @@ ], ), ) -def request_session( +async def request_session( body: models.PostSessionRequest, user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user @@ -96,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), @@ -136,11 +142,12 @@ def request_session( connection_method=connection_method, provisioning=body.provisioning, session_id=session_id, + project_scope=project_scope, ) - for hook in hooks.get_activated_integration_hooks(tool): - hook_result = hook.configuration_hook(hook_request) - + 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", []) @@ -227,8 +234,7 @@ 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( hooks_interface.PostSessionCreationHookRequest( session_id=session_id, @@ -238,7 +244,7 @@ def request_session( db_session=db_session, connection_method=connection_method, db=db, - ), + ) ) hook_config |= result.get("config", {}) @@ -374,7 +380,7 @@ 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( hooks_interface.SessionConnectionHookRequest( db=db, diff --git a/backend/capellacollab/sessions/util.py b/backend/capellacollab/sessions/util.py index a4102107f0..692ebfedd3 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 @@ -207,3 +208,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 5115dca55b..b45a12a579 100644 --- a/backend/capellacollab/tools/models.py +++ b/backend/capellacollab/tools/models.py @@ -289,6 +289,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 92c6175b4e..2b6cacc8ca 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/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 index 2a8f5d2d55..4c8658b858 100644 --- a/backend/tests/sessions/hooks/conftest.py +++ b/backend/tests/sessions/hooks/conftest.py @@ -31,14 +31,15 @@ def fixture_configuration_hook_request( 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( + db: orm.Session, session: sessions_models.DatabaseSession, user: users_models.DatabaseUser, - db: orm.Session, ) -> hooks_interface.PostSessionCreationHookRequest: return hooks_interface.PostSessionCreationHookRequest( session_id="test", 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 9cf3de2307..0c6ac69534 100644 --- a/backend/tests/sessions/hooks/test_provisioning_hook.py +++ b/backend/tests/sessions/hooks/test_provisioning_hook.py @@ -5,11 +5,18 @@ 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 @@ -18,8 +25,9 @@ from capellacollab.users import models as users_models +@pytest.mark.asyncio @pytest.mark.usefixtures("project_user") -def test_git_models_are_resolved_correctly( +async def test_read_only_git_models_are_resolved_correctly( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, git_model: git_models.DatabaseGitModel, @@ -39,7 +47,7 @@ def test_git_models_are_resolved_correctly( ) ] - response = hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( + response = await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( configuration_hook_request ) @@ -65,6 +73,7 @@ def test_git_models_are_resolved_correctly( ] +@pytest.mark.asyncio async def test_provisioning_fails_missing_permission( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, @@ -82,13 +91,14 @@ async def test_provisioning_fails_missing_permission( ) ] with pytest.raises(fastapi.HTTPException): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( + 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( +async def test_provisioning_fails_too_many_models_requested( capella_tool: tools_models.DatabaseTool, project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, @@ -112,12 +122,13 @@ def test_provisioning_fails_too_many_models_requested( with pytest.raises( sessions_exceptions.TooManyModelsRequestedToProvisionError ): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( + await hooks_provisioning.ProvisionWorkspaceHook().async_configuration_hook( configuration_hook_request ) -def test_tool_model_mismatch( +@pytest.mark.asyncio +async def test_tool_model_mismatch( project: projects_models.DatabaseProject, capella_model: toolmodels_models.DatabaseToolModel, tool_version: tools_models.DatabaseVersion, @@ -136,12 +147,13 @@ def test_tool_model_mismatch( ) ] with pytest.raises(sessions_exceptions.ToolAndModelMismatchError): - hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( + 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, tool_version: tools_models.DatabaseVersion, capella_tool_version: tools_models.DatabaseVersion, @@ -170,7 +182,208 @@ def test_provision_session_with_compatible_tool_versions( ) ] configuration_hook_request.user.role = users_models.Role.ADMIN - response = hooks_provisioning.ProvisionWorkspaceHook().configuration_hook( + 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/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 8e6a122a18..583e159e2f 100644 --- a/backend/tests/sessions/test_session_hooks.py +++ b/backend/tests/sessions/test_session_hooks.py @@ -39,6 +39,7 @@ 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 @@ -49,6 +50,12 @@ def configuration_hook( 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, request: hooks_interface.PostSessionCreationHookRequest ) -> hooks_interface.PostSessionCreationHookResult: @@ -94,8 +101,9 @@ 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, @@ -117,7 +125,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, @@ -132,6 +140,7 @@ def test_hook_calls_during_session_request( ) 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 diff --git a/backend/tests/sessions/test_session_routes.py b/backend/tests/sessions/test_session_routes.py index 3e0e582fa0..879a855b50 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 ac0d1317b0..159adc4454 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/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/package-lock.json b/frontend/package-lock.json index aae986a070..837b84be46 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,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", @@ -819,6 +820,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", @@ -2781,6 +2806,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", @@ -3211,6 +3287,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", @@ -4039,6 +4128,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", @@ -4602,6 +4714,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", @@ -6421,6 +6543,300 @@ "@types/node": "*" } }, + "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/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/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/d3-selection": "*" + } + }, + "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/d3-selection": "*" + } + }, + "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", + "optional": 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/d3-array": "*", + "@types/geojson": "*" + } + }, + "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/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/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/d3-selection": "*" + } + }, + "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/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/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/d3-dsv": "*" + } + }, + "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": { + "@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", @@ -6495,6 +6911,13 @@ "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", @@ -6676,6 +7099,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", @@ -7247,7 +7677,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" @@ -8539,6 +8969,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", @@ -8825,6 +9283,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", @@ -9102,6 +9572,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", @@ -9302,6 +9779,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", @@ -9432,25 +9919,589 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "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": ">=0.10" + } + }, + "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/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "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": ">=4" + "node": ">=12" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" + "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", @@ -9471,6 +10522,13 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "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", @@ -9647,6 +10705,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", @@ -9656,6 +10724,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", @@ -9808,6 +10883,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", @@ -9883,6 +10965,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", @@ -11625,6 +12714,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", @@ -11650,6 +12749,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", @@ -12337,6 +13443,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", @@ -12980,6 +14096,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", @@ -12996,6 +14139,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", @@ -13005,6 +14154,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", @@ -13016,6 +14189,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", @@ -13285,6 +14465,23 @@ "node": ">= 12.13.0" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "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", @@ -13307,6 +14504,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", @@ -13679,16 +14883,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": { @@ -13751,6 +14955,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", @@ -14120,6 +15381,19 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mlly": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.3.tgz", + "integrity": "sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^1.1.2", + "pkg-types": "^1.2.1", + "ufo": "^1.5.4" + } + }, "node_modules/mockdate": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", @@ -14378,6 +15652,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", @@ -18157,6 +19455,13 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.4.tgz", + "integrity": "sha512-H/OUu9/zUfP89z1APcBf2X8Us0tt8dUK4lUmKqz12QNXif3DxAs1/YqjGtcutZi1zQqeNQRWr9C+EbQnnvSSFA==", + "license": "MIT", + "optional": true + }, "node_modules/pacote": { "version": "18.0.6", "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", @@ -18338,6 +19643,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", @@ -18419,6 +19731,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", @@ -18602,6 +19921,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", @@ -19090,7 +20439,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" @@ -19977,6 +21326,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", @@ -20020,6 +21376,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", @@ -20057,6 +21426,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", @@ -20213,6 +21589,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", @@ -21928,6 +23311,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", @@ -22412,12 +23802,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", @@ -22606,7 +24010,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" @@ -22775,6 +24179,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", @@ -23679,6 +25090,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", @@ -24401,9 +25867,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", - "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index 5a2a462ef3..603bee07ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,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", diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index a0af36677b..7a09738116 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 { SessionViewerComponent } from 'src/app/sessions/session/session-viewer.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/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index f929e2f765..048a44bf5c 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 @@ -95,6 +98,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 @@ -126,12 +130,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 @@ -169,6 +175,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 68b9f42e16..adbdbd36d1 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -74,6 +74,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'; @@ -104,6 +105,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'; @@ -111,6 +113,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'; @@ -148,6 +151,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/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.ts b/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.ts index 6c5ea3f145..d59209dfbf 100644 --- a/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.ts +++ b/frontend/src/app/projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component.ts @@ -3,14 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ import { CommonModule } from '@angular/common'; -import { HttpContext } from '@angular/common/http'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { filter, map, switchMap } from 'rxjs'; -import { SKIP_ERROR_HANDLING } from 'src/app/general/error-handling/error-handling.interceptor'; +import { SKIP_ERROR_HANDLING_CONTEXT } from 'src/app/general/error-handling/error-handling.interceptor'; import { ProjectsModelsModelComplexityBadgeService } from 'src/app/openapi'; import { ProjectWrapperService } from 'src/app/projects/service/project.service'; import { environment } from 'src/environments/environment'; @@ -60,7 +59,7 @@ export class ModelComplexityBadgeComponent implements OnChanges { undefined, undefined, { - context: new HttpContext().set(SKIP_ERROR_HANDLING, true), + context: SKIP_ERROR_HANDLING_CONTEXT, }, ); }), 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 471937ffbe..f41d79dcfb 100644 --- a/frontend/src/app/projects/project-detail/project-details.component.html +++ b/frontend/src/app/projects/project-detail/project-details.component.html @@ -7,10 +7,22 @@
- + @if ((projectService.project$ | async)?.type !== "training") { + + } + @if (betaTestingService.isBetaTester$ | async) { + + }
- + @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 e32cfd994f..01683eaa33 100644 --- a/frontend/src/app/projects/project-detail/project-details.component.ts +++ b/frontend/src/app/projects/project-detail/project-details.component.ts @@ -2,8 +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 { TrainingDetailsComponent } from 'src/app/projects/project-detail/training-details/training-details.component'; +import { CreateProvisionedSessionComponent } from 'src/app/sessions/user-sessions-wrapper/create-sessions/create-provisioned-session/create-provisioned-session.component'; +import { BetaTestingService } from 'src/app/users/users-profile/beta-testing/beta-testing.service'; import { CreateReadonlySessionComponent } from '../../sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session.component'; import { ProjectWrapperService } from '../service/project.service'; import { ModelOverviewComponent } from './model-overview/model-overview.component'; @@ -19,11 +25,21 @@ 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, + public betaTestingService: BetaTestingService, + 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 06a7189cea..7f63ca46bf 100644 --- a/frontend/src/app/projects/project-detail/project-details.stories.ts +++ b/frontend/src/app/projects/project-detail/project-details.stories.ts @@ -3,7 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { + mockProject, + mockProjectWrapperServiceProvider, +} from 'src/storybook/project'; import { mockProjectUserServiceProvider } from 'src/storybook/project-users'; +import { + mockOwnUserWrapperServiceProvider, + mockUser, +} from 'src/storybook/user'; import { ProjectDetailsComponent } from './project-details.component'; const meta: Meta = { @@ -16,13 +24,59 @@ type Story = StoryObj; export const Loading: Story = { args: {}, + decorators: [ + moduleMetadata({ + providers: [ + mockOwnUserWrapperServiceProvider({ ...mockUser, beta_tester: true }), + ], + }), + ], }; export const LoadingAsProjectLead: Story = { args: {}, decorators: [ moduleMetadata({ - providers: [mockProjectUserServiceProvider('manager')], + providers: [ + mockProjectUserServiceProvider('manager'), + mockOwnUserWrapperServiceProvider({ ...mockUser, beta_tester: true }), + ], + }), + ], +}; + +export const ProjectLoaded: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + mockProjectWrapperServiceProvider(mockProject, undefined), + mockOwnUserWrapperServiceProvider({ ...mockUser, beta_tester: true }), + ], + }), + ], +}; + +export const ProjectLoadedNonBeta: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [mockProjectWrapperServiceProvider(mockProject, undefined)], + }), + ], +}; + +export const TrainingLoaded: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + mockProjectWrapperServiceProvider( + { ...mockProject, type: 'training' }, + undefined, + ), + mockOwnUserWrapperServiceProvider({ ...mockUser, beta_tester: true }), + ], }), ], }; 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..66eb2c512b --- /dev/null +++ b/frontend/src/app/projects/project-detail/project-tools/project-tools.stories.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { + mockProject, + mockProjectWrapperServiceProvider, +} from 'src/storybook/project'; +import { + mockProjectTool, + projectToolServiceProvider, +} from 'src/storybook/project-tools'; +import { mockProjectUserServiceProvider } 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: [ + mockProjectUserServiceProvider('manager', undefined, undefined), + ], + }), + ], +}; + +export const ArchivedProject: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + mockProjectWrapperServiceProvider( + { ...mockProject, is_archived: true }, + undefined, + ), + mockProjectUserServiceProvider('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), + mockProjectUserServiceProvider('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..e2b676d311 --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.component.html @@ -0,0 +1,146 @@ + + +
+
+

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 (readmes.get(model.slug); as readme) { + @if (readme.readme) { + + } @else if (readme.errorCode) { +
+ @if (readme.errorCode === "FILE_NOT_FOUND") { + info + + README not set up.
+ To set up a description for this training, create a + README.md file in the root of the linked repository. +
+ } @else { + error + Error loading the README.
{{ + readme.errorMessage || + "Please ask your project administrator or global administrator for help." + }}
+ } +
+ } + } +
+
+ @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..db6d5265fd --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.component.ts @@ -0,0 +1,107 @@ +/* + * 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({ + next: (readme) => { + this.readmes.set(model.slug, { readme }); + }, + error: (error) => { + error = JSON.parse(error.error); + this.readmes.set(model.slug, { + errorMessage: error?.detail.reason, + errorCode: error?.detail?.err_code, + }); + }, + }); + } + }); + } +} + +interface Readme { + errorMessage?: string; + errorCode?: string; + readme?: string; +} 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..7132611f66 --- /dev/null +++ b/frontend/src/app/projects/project-detail/training-details/training-details.stories.ts @@ -0,0 +1,121 @@ +/* + * 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 ReadmeNotFound: Story = { + args: { + readmes: new Map([[mockModel.slug, { errorCode: 'FILE_NOT_FOUND' }]]), + }, + decorators: [ + moduleMetadata({ + providers: [mockModelWrapperServiceProvider(undefined, [mockModel])], + }), + ], +}; + +export const ReadmeLoadingFailed: Story = { + args: { + readmes: new Map([ + [ + mockModel.slug, + { + errorCode: 'GIT_REPOSITORY_NOT_FOUND', + errorMessage: `No Git repository with the ID '1' found for the model with slug ${mockModel.slug}.`, + }, + ], + ]), + }, + 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/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-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/storybook/model.ts b/frontend/src/storybook/model.ts index d08db2e5b1..619fc13252 100644 --- a/frontend/src/storybook/model.ts +++ b/frontend/src/storybook/model.ts @@ -3,7 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ import { BehaviorSubject } from 'rxjs'; -import { SimpleToolModel, ToolModel } from 'src/app/openapi'; +import { + SimpleToolModel, + SimpleToolModelWithoutProject, + ToolModel, +} from 'src/app/openapi'; import { ModelWrapperService } from 'src/app/projects/models/service/model.service'; import { mockProject } from 'src/storybook/project'; import { mockPrimaryGitModel } from './git'; @@ -34,6 +38,13 @@ export const mockSimpleToolModel: Readonly = { ...mockModel, project: mockProject, }; +export const mockSimpleToolModelWithoutProject: Readonly = + { + id: 1, + slug: 'in-flight-entertainment-system', + name: 'In-Flight Entertainment System', + git_models: [mockPrimaryGitModel], + }; class MockModelWrapperService implements Partial { private _model = new BehaviorSubject(undefined); diff --git a/frontend/src/storybook/project-tools.ts b/frontend/src/storybook/project-tools.ts new file mode 100644 index 0000000000..21605f8a17 --- /dev/null +++ b/frontend/src/storybook/project-tools.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { BehaviorSubject } from 'rxjs'; +import { ProjectTool } from 'src/app/openapi'; +import { ProjectToolsWrapperService } from 'src/app/projects/project-detail/project-tools/project-tools-wrapper.service'; +import { mockModel } from 'src/storybook/model'; +import { + mockCapellaTool, + mockCapellaToolVersion, + mockOtherToolVersion, + mockTrainingControllerTool, +} from 'src/storybook/tool'; + +class MockProjectToolsService implements Partial { + private readonly _projectTools = new BehaviorSubject< + ProjectTool[] | undefined + >(undefined); + readonly projectTools$ = this._projectTools.asObservable(); + + constructor(projectTools: ProjectTool[] | undefined) { + this._projectTools.next(projectTools); + } +} + +export const projectToolServiceProvider = ( + projectTools: ProjectTool[] | undefined, +) => { + return { + provide: ProjectToolsWrapperService, + useValue: new MockProjectToolsService(projectTools), + }; +}; + +export const mockProjectTool: ProjectTool = { + id: 1, + tool_version: mockCapellaToolVersion, + tool: mockCapellaTool, + used_by: [mockModel], +}; + +export const mockTrainingTool: ProjectTool = { + id: 2, + tool_version: mockOtherToolVersion, + tool: mockTrainingControllerTool, + used_by: [], +}; diff --git a/frontend/src/storybook/tool.ts b/frontend/src/storybook/tool.ts index 839bcc7f7f..a1eb598614 100644 --- a/frontend/src/storybook/tool.ts +++ b/frontend/src/storybook/tool.ts @@ -52,6 +52,12 @@ export const mockCapellaToolVersion: Readonly = { config: defaultToolVersionConfig, }; +export const mockOtherToolVersion: Readonly = { + id: 2, + name: 'Latest', + config: defaultToolVersionConfig, +}; + export const mockToolNature: Readonly = { id: 1, name: 'Project', @@ -64,6 +70,7 @@ const defaultToolConfig: ToolSessionConfigurationOutput = { provisioning: { directory: '/tmp', max_number_of_models: null, + required: false, }, persistent_workspaces: { mounting_enabled: true, @@ -102,6 +109,17 @@ export const mockCapellaTool: Readonly = { config: defaultToolConfig, }; +export const mockTrainingControllerTool: Readonly = { + id: 2, + name: 'Training Controller', + integrations: { + t4c: false, + pure_variants: false, + jupyter: false, + }, + config: { ...defaultToolConfig, supported_project_types: ['training'] }, +}; + export const mockToolVersionWithTool: Readonly = { ...mockCapellaToolVersion, tool: mockCapellaTool, diff --git a/images/session-preparation/clone_repositories.py b/images/session-preparation/clone_repositories.py index ecbc2adc1f..4b3462a3b7 100644 --- a/images/session-preparation/clone_repositories.py +++ b/images/session-preparation/clone_repositories.py @@ -10,6 +10,8 @@ import json import logging import os +import pathlib +import shutil import subprocess import typing as t @@ -37,21 +39,38 @@ class _ProjectDict(t.TypedDict): path: str -def fetch_projects_from_environment() -> list[_ProjectDict]: +def _fetch_projects_from_environment() -> list[_ProjectDict]: return json.loads(environment.GIT_REPOS_JSON) -def clone_git_repository(project: _ProjectDict) -> None: +def _backup_directory_if_exists(path: pathlib.Path) -> None: + if not path.exists(): + return + + log.info("Backing up existing directory %s", path) + backup_path = path.with_name(f"{path.name}.bak") + + if backup_path.exists(): + log.info("Removing existing backup directory %s", backup_path) + shutil.rmtree(path) + log.info("Removed existing backup directory %s", backup_path) + + path.rename(backup_path) + log.info("Backed up existing directory %s to %s", path, backup_path) + + +def _clone_git_repository(project: _ProjectDict) -> None: log.info("Cloning git repository with url %s", project["url"]) flags = [] - if revision := project["revision"]: - flags += ["--single-branch", "--branch", revision] - git_depth = project["depth"] + revision = project["revision"] + if git_depth != 0: flags += ["--depth", str(git_depth)] + if revision: + flags += ["--single-branch", "--branch", revision] try: subprocess.run( @@ -63,6 +82,13 @@ def clone_git_repository(project: _ProjectDict) -> None: "GIT_ASKPASS": "/etc/git_askpass.py", }, ) + if git_depth == 0 and revision: + subprocess.run( + ["git", "-c", "advice.detachedHead=false", "checkout", revision], + check=True, + cwd=project["path"], + ) + except subprocess.CalledProcessError as e: log.info("---FAILURE_PREPARE_WORKSPACE---") log.error( @@ -71,6 +97,7 @@ def clone_git_repository(project: _ProjectDict) -> None: e.returncode, ) raise + log.info( "Successfully cloned Git repository with url '%s' to '%s'", project["url"], @@ -81,9 +108,10 @@ def clone_git_repository(project: _ProjectDict) -> None: def main(): log.info("Starting preparation of session") log.info("---START_PREPARE_WORKSPACE---") - projects = fetch_projects_from_environment() + projects = _fetch_projects_from_environment() for project in projects: - clone_git_repository(project) + _backup_directory_if_exists(pathlib.Path(project["path"])) + _clone_git_repository(project) log.info("Finished preparation of session") log.info("---FINISH_PREPARE_WORKSPACE---")