From 117be933311dbff6ab52a960b4a2ae3049e46168 Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Mon, 15 Jul 2024 18:35:12 +0200 Subject: [PATCH] feat: Add workspace management for admins --- ...345cc9_cut_tool_names_that_are_too_long.py | 2 - .../a1e59021e0d0_add_workspaces_table.py | 60 +++++ backend/capellacollab/cli/ws.py | 136 +++++++--- backend/capellacollab/core/database/models.py | 1 + backend/capellacollab/events/crud.py | 10 + .../sessions/hooks/persistent_workspace.py | 45 +++- .../capellacollab/sessions/operators/k8s.py | 10 +- backend/capellacollab/users/routes.py | 8 + .../users/workspaces/__init__.py | 2 + .../capellacollab/users/workspaces/crud.py | 52 ++++ .../users/workspaces/exceptions.py | 16 ++ .../users/workspaces/injectables.py | 25 ++ .../capellacollab/users/workspaces/models.py | 31 +++ .../capellacollab/users/workspaces/routes.py | 57 ++++ .../capellacollab/users/workspaces/util.py | 37 +++ backend/tests/cli/test_workspace_backup.py | 160 +++++++++-- backend/tests/sessions/fixtures.py | 22 +- .../hooks/test_persistent_workspace.py | 109 +++++++- backend/tests/users/fixtures.py | 19 ++ backend/tests/users/test_users.py | 45 +++- backend/tests/users/test_workspaces.py | 76 ++++++ frontend/src/app/app-routing.module.ts | 17 +- .../confirmation-dialog.component.html | 36 +-- .../confirmation-dialog.component.ts | 3 +- .../confirmation-dialog.stories.ts | 75 ++++++ .../src/app/openapi/.openapi-generator/FILES | 2 + frontend/src/app/openapi/api/api.ts | 4 +- .../openapi/api/users-workspaces.service.ts | 255 ++++++++++++++++++ frontend/src/app/openapi/api/users.service.ts | 160 +++++++++++ frontend/src/app/openapi/model/models.ts | 1 + frontend/src/app/openapi/model/workspace.ts | 19 ++ .../user-settings/user-settings.component.ts | 7 +- .../common-projects.component.html | 39 +++ .../common-projects.component.ts | 46 ++++ .../common-projects.stories.ts | 54 ++++ .../user-information.component.html | 79 ++++++ .../user-information.component.ts | 82 ++++++ .../user-information.stories.ts | 88 ++++++ .../user-workspaces.component.html | 48 ++++ .../user-workspaces.component.ts | 95 +++++++ .../user-workspaces.stories.ts | 53 ++++ .../users-profile/users-profile.component.css | 9 - .../users-profile.component.html | 124 +-------- .../users-profile/users-profile.component.ts | 91 +------ 44 files changed, 2007 insertions(+), 303 deletions(-) create mode 100644 backend/capellacollab/alembic/versions/a1e59021e0d0_add_workspaces_table.py create mode 100644 backend/capellacollab/users/workspaces/__init__.py create mode 100644 backend/capellacollab/users/workspaces/crud.py create mode 100644 backend/capellacollab/users/workspaces/exceptions.py create mode 100644 backend/capellacollab/users/workspaces/injectables.py create mode 100644 backend/capellacollab/users/workspaces/models.py create mode 100644 backend/capellacollab/users/workspaces/routes.py create mode 100644 backend/capellacollab/users/workspaces/util.py create mode 100644 backend/tests/users/test_workspaces.py create mode 100644 frontend/src/app/helpers/confirmation-dialog/confirmation-dialog.stories.ts create mode 100644 frontend/src/app/openapi/api/users-workspaces.service.ts create mode 100644 frontend/src/app/openapi/model/workspace.ts create mode 100644 frontend/src/app/users/users-profile/common-projects/common-projects.component.html create mode 100644 frontend/src/app/users/users-profile/common-projects/common-projects.component.ts create mode 100644 frontend/src/app/users/users-profile/common-projects/common-projects.stories.ts create mode 100644 frontend/src/app/users/users-profile/user-information/user-information.component.html create mode 100644 frontend/src/app/users/users-profile/user-information/user-information.component.ts create mode 100644 frontend/src/app/users/users-profile/user-information/user-information.stories.ts create mode 100644 frontend/src/app/users/users-profile/user-workspaces/user-workspaces.component.html create mode 100644 frontend/src/app/users/users-profile/user-workspaces/user-workspaces.component.ts create mode 100644 frontend/src/app/users/users-profile/user-workspaces/user-workspaces.stories.ts delete mode 100644 frontend/src/app/users/users-profile/users-profile.component.css diff --git a/backend/capellacollab/alembic/versions/3ec39e345cc9_cut_tool_names_that_are_too_long.py b/backend/capellacollab/alembic/versions/3ec39e345cc9_cut_tool_names_that_are_too_long.py index 4c29baea9..628c25dcf 100644 --- a/backend/capellacollab/alembic/versions/3ec39e345cc9_cut_tool_names_that_are_too_long.py +++ b/backend/capellacollab/alembic/versions/3ec39e345cc9_cut_tool_names_that_are_too_long.py @@ -8,8 +8,6 @@ Create Date: 2024-02-23 08:53:31.142987 """ -import uuid - import sqlalchemy as sa from alembic import op diff --git a/backend/capellacollab/alembic/versions/a1e59021e0d0_add_workspaces_table.py b/backend/capellacollab/alembic/versions/a1e59021e0d0_add_workspaces_table.py new file mode 100644 index 000000000..dc88431eb --- /dev/null +++ b/backend/capellacollab/alembic/versions/a1e59021e0d0_add_workspaces_table.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add workspaces table + +Revision ID: a1e59021e0d0 +Revises: 49f51db92903 +Create Date: 2024-07-17 09:19:57.903328 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1e59021e0d0" +down_revision = "49f51db92903" +branch_labels = None +depends_on = None + +t_users = sa.Table( + "users", + sa.MetaData(), + sa.Column("id", sa.Integer()), + sa.Column("name", sa.String()), +) + + +def upgrade(): + connection = op.get_bind() + users = connection.execute(sa.select(t_users)).mappings().all() + + t_workspaces = op.create_table( + "workspaces", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("pvc_name", sa.String(), nullable=False), + sa.Column("size", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id", "user_id"), + sa.UniqueConstraint("pvc_name"), + ) + op.create_index( + op.f("ix_workspaces_id"), "workspaces", ["id"], unique=False + ) + + for user in users: + pvc_name = ( + "persistent-session-" + + user["name"].replace("@", "-at-").replace(".", "-dot-").lower() + ) + connection.execute( + t_workspaces.insert().values( + pvc_name=pvc_name, + size="20Gi", + user_id=user["id"], + ) + ) diff --git a/backend/capellacollab/cli/ws.py b/backend/capellacollab/cli/ws.py index 892cc53d9..3312470e1 100644 --- a/backend/capellacollab/cli/ws.py +++ b/backend/capellacollab/cli/ws.py @@ -8,6 +8,7 @@ import contextlib import datetime import enum +import json import logging import pathlib import select @@ -15,6 +16,7 @@ import time import typing as t +import pydantic import typer import websocket from rich import console, pretty, table @@ -27,11 +29,17 @@ ) MOUNT_PATH = "/workspace" -PERSISTENT_SESSION_PREFIX = "persistent-session-" +LEGACY_WORKSPACE_PREFIX = "persistent-session-" +PERSISTENT_WORKSPACE_PREFIX = "workspace-" LOGGER = logging.getLogger(__name__) +class Sidecar(pydantic.BaseModel): + size: str | None = "20Gi" + annotations: dict[str, str] = {} + + def init_kube(): from kubernetes import config @@ -121,25 +129,42 @@ def volumes( access_modes = ", ".join(item.spec.access_modes) age: datetime.datetime = item.metadata.creation_timestamp - if pvc_name.startswith(PERSISTENT_SESSION_PREFIX): + if pvc_name.startswith(PERSISTENT_WORKSPACE_PREFIX): + annotations = item.metadata.annotations + volume_type = "Persistent user workspace" + if pvc_name.startswith(LEGACY_WORKSPACE_PREFIX): annotations = { "capellacollab/username": pvc_name.removeprefix( - PERSISTENT_SESSION_PREFIX + LEGACY_WORKSPACE_PREFIX ), } - volume_type = "Persistent user workspace" + volume_type = "Persistent user workspace (legacy)" elif pvc_name.startswith("shared-workspace-"): - annotations = { - "capellacollab/project_slug": item.metadata.labels.get( - "capellacollab/project_slug" - ), - } + annotations = ( + { + "capellacollab/project_slug": ( + item.metadata.labels.get("capellacollab/project_slug") + ), + } + if item.metadata.labels + else {} + ) volume_type = "Project-level file-share" + filtered_annotations = ( + { + key.removeprefix("capellacollab/"): value + for key, value in annotations.items() + if key.startswith("capellacollab/") + } + if annotations + else {} + ) + tbl.add_row( pvc_name, volume_type, - pretty.Pretty(annotations), + pretty.Pretty(filtered_annotations), capacity, storage_class, access_modes, @@ -160,6 +185,7 @@ def ls( init_kube() with pod_for_volume(volume_name, namespace) as pod_name: + wait_for_pod(pod_name, namespace) for data in stream_tar_from_pod(pod_name, namespace, ["ls", path]): sys.stdout.write(data.decode("utf-8", "replace")) @@ -171,11 +197,31 @@ def backup( out: pathlib.Path = pathlib.Path.cwd(), ): """Create a backup of all content in a Kubernetes Persistent Volume.""" + from kubernetes import client + init_kube() + core_api = client.CoreV1Api() targz = out / f"{volume_name}.tar.gz" + sidecar = out / f"{volume_name}.json" + + pvc: client.V1PersistentVolumeClaim = ( + core_api.read_namespaced_persistent_volume_claim( + name=volume_name, namespace=namespace + ) + ) + + sidecar.write_text( + json.dumps( + Sidecar( + size=pvc.spec.resources.requests.get("storage", None), + annotations=pvc.metadata.annotations, + ).model_dump() + ) + ) with pod_for_volume(volume_name, namespace) as pod_name: + wait_for_pod(pod_name, namespace) print(f"Downloading workspace volume to '{targz}'") with targz.open("wb") as outfile: @@ -190,6 +236,7 @@ def restore( volume_name: str, tarfile: t.Annotated[pathlib.Path, typer.Argument(exists=True)], namespace: t.Annotated[str, NamespaceOption], + sidecar_path: t.Union[pathlib.Path, None] = None, access_mode: str = "ReadWriteMany", storage_class_name: str = "persistent-sessions-csi", user_id: t.Union[str, None] = None, @@ -206,21 +253,39 @@ def restore( init_kube() + sidecar = Sidecar() + if sidecar_path and sidecar_path.exists(): + print(f"Found sidecar at '{sidecar_path}'") + sidecar = Sidecar.model_validate_json(sidecar_path.read_text()) + create_persistent_volume( - volume_name, namespace, access_mode, storage_class_name + volume_name, namespace, access_mode, storage_class_name, sidecar ) with pod_for_volume(volume_name, namespace, read_only=False) as pod_name: + wait_for_pod(pod_name, namespace) print(f"Restoring workspace volume to '{volume_name}'") with tarfile.open("rb") as infile: stream_tar_to_pod(pod_name, namespace, infile) - adjust_directory_permissions( - pod_name, - namespace, - user_id, - ) + if user_id: + adjust_directory_permissions( + pod_name, + namespace, + user_id, + ) + + +def wait_for_pod( + pod_name: str, + namespace: str, +): + timeout = 300 # seconds + while not is_pod_ready(pod_name, namespace) and timeout > 0: + print("Waiting for pod to come online...") + time.sleep(2) + timeout -= 2 @contextlib.contextmanager @@ -273,16 +338,26 @@ def pod_for_volume( ), ) - core_v1_api.create_namespaced_pod(namespace, pod) + print( + f"Creating pod with name '{volume_name}' in namespace '{namespace}'..." + ) - timeout = 300 # seconds - while not is_pod_ready(volume_name, namespace) and timeout > 0: - print("Waiting for pod to come online...") - time.sleep(2) - timeout -= 2 + try: + core_v1_api.create_namespaced_pod(namespace, pod) + except client.exceptions.ApiException as e: + if e.status == 409: + print( + f"The pod with name '{volume_name}' already exists. " + "If the Pod is in terminating state, try again later. " + "Otherwise, delete it manually." + ) + sys.exit(1) + else: + raise yield volume_name + print("Deleting pod...") core_v1_api.delete_namespaced_pod(volume_name, namespace) @@ -291,6 +366,7 @@ def create_persistent_volume( namespace: str, access_mode: str, storage_class_name: str, + sidecar: Sidecar, ): """Rebuild a PVC, according to the config defined in `capellacollab/sessions/hooks/persistent_workspace.py`. @@ -300,26 +376,17 @@ def create_persistent_volume( core_v1_api = client.CoreV1Api() - username = ( - name[len(PERSISTENT_SESSION_PREFIX) :] - if name.startswith(PERSISTENT_SESSION_PREFIX) - else name - ) - pvc = client.V1PersistentVolumeClaim( kind="PersistentVolumeClaim", api_version="v1", metadata=client.V1ObjectMeta( - name=name, - labels={ - "capellacollab/username": username, - }, + name=name, annotations=sidecar.annotations ), spec=client.V1PersistentVolumeClaimSpec( access_modes=[access_mode], storage_class_name=storage_class_name, resources=client.V1ResourceRequirements( - requests={"storage": "20Gi"} + requests={"storage": sidecar.size} ), ), ) @@ -337,7 +404,7 @@ def create_persistent_volume( def adjust_directory_permissions( pod_name: str, namespace: str, - user_id: str | None, + user_id: str, directory: str = MOUNT_PATH, ): from kubernetes import client, stream @@ -420,7 +487,6 @@ def stream_tar_to_pod(pod_name, namespace, infile): pod_name, namespace, command=["tar", "zxf", "-", "-C", "/"], - # command=["cat"], stderr=True, stdin=True, stdout=True, diff --git a/backend/capellacollab/core/database/models.py b/backend/capellacollab/core/database/models.py index 6ad2c6a72..f44403e14 100644 --- a/backend/capellacollab/core/database/models.py +++ b/backend/capellacollab/core/database/models.py @@ -22,3 +22,4 @@ import capellacollab.tools.models import capellacollab.users.models import capellacollab.users.tokens.models +import capellacollab.users.workspaces.models diff --git a/backend/capellacollab/events/crud.py b/backend/capellacollab/events/crud.py index 12954b949..eac42825a 100644 --- a/backend/capellacollab/events/crud.py +++ b/backend/capellacollab/events/crud.py @@ -111,6 +111,16 @@ def get_events( ) +def get_event_by_id( + db: orm.Session, event_id: int +) -> models.DatabaseUserHistoryEvent | None: + return db.execute( + sa.select(models.DatabaseUserHistoryEvent).where( + models.DatabaseUserHistoryEvent.id == event_id + ) + ).scalar_one_or_none() + + def delete_all_events_user_involved_in(db: orm.Session, user_id: int): db.execute( sa.delete(models.DatabaseUserHistoryEvent).where( diff --git a/backend/capellacollab/sessions/hooks/persistent_workspace.py b/backend/capellacollab/sessions/hooks/persistent_workspace.py index 9e570a084..5086f57a7 100644 --- a/backend/capellacollab/sessions/hooks/persistent_workspace.py +++ b/backend/capellacollab/sessions/hooks/persistent_workspace.py @@ -3,6 +3,9 @@ import pathlib import typing as t +import uuid + +from sqlalchemy import orm from capellacollab.sessions import exceptions as sessions_exceptions from capellacollab.sessions import models as sessions_models @@ -10,6 +13,8 @@ from capellacollab.sessions.operators import models as operators_models from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models +from capellacollab.users.workspaces import crud as users_workspaces_crud +from capellacollab.users.workspaces import models as users_workspaces_models from . import interface @@ -26,6 +31,7 @@ class PersistentWorkspaceHook(interface.HookRegistration): def configuration_hook( # type: ignore self, + db: orm.Session, operator: operators.KubernetesOperator, user: users_models.DatabaseUser, session_type: sessions_models.SessionType, @@ -38,7 +44,7 @@ def configuration_hook( # type: ignore self._check_that_persistent_workspace_is_allowed(tool) - volume_name = self._create_persistent_workspace(operator, user.name) + volume_name = self._create_persistent_workspace(db, operator, user) volume = operators_models.PersistentVolume( name="workspace", read_only=False, @@ -58,21 +64,36 @@ def _check_that_persistent_workspace_is_allowed( tool.name ) - def _get_volume_name(self, username: str) -> str: - return "persistent-session-" + self._normalize_username(username) - - def _normalize_username(self, username: str) -> str: - return username.replace("@", "-at-").replace(".", "-dot-").lower() - def _create_persistent_workspace( - self, operator: operators.KubernetesOperator, username: str + self, + db: orm.Session, + operator: operators.KubernetesOperator, + user: users_models.DatabaseUser, ) -> str: - persistent_workspace_name = self._get_volume_name(username) + workspaces = users_workspaces_crud.get_workspaces_for_user(db, user) + persistent_workspace_name = "workspace-" + str(uuid.uuid4()) + size = "20Gi" + + if len(workspaces) > 0: + persistent_workspace_name = workspaces[0].pvc_name + else: + users_workspaces_crud.create_workspace( + db, + workspace=users_workspaces_models.DatabaseWorkspace( + pvc_name=persistent_workspace_name, + size=size, + user=user, + ), + ) + operator.create_persistent_volume( persistent_workspace_name, - "20Gi", - labels={ - "capellacollab/username": self._normalize_username(username), + size, + annotations={ + "capellacollab/username": user.name, + "capellacollab/user-id": str(user.id), + "capellacollab/volume": "personal-workspace", }, ) + return persistent_workspace_name diff --git a/backend/capellacollab/sessions/operators/k8s.py b/backend/capellacollab/sessions/operators/k8s.py index 9ede0ebff..6b3da6e76 100644 --- a/backend/capellacollab/sessions/operators/k8s.py +++ b/backend/capellacollab/sessions/operators/k8s.py @@ -676,12 +676,18 @@ def persistent_volume_exists(self, name: str) -> bool: return True def create_persistent_volume( - self, name: str, size: str, labels: dict[str, str] | None = None + self, + name: str, + size: str, + labels: dict[str, str] | None = None, + annotations: dict[str, str] | None = None, ): pvc: client.V1PersistentVolumeClaim = client.V1PersistentVolumeClaim( kind="PersistentVolumeClaim", api_version="v1", - metadata=client.V1ObjectMeta(name=name, labels=labels), + metadata=client.V1ObjectMeta( + name=name, labels=labels, annotations=annotations + ), spec=client.V1PersistentVolumeClaimSpec( access_modes=[cfg.storage_access_mode], storage_class_name=cfg.storage_class_name, diff --git a/backend/capellacollab/users/routes.py b/backend/capellacollab/users/routes.py index 775eefbcb..680d4e08c 100644 --- a/backend/capellacollab/users/routes.py +++ b/backend/capellacollab/users/routes.py @@ -17,6 +17,8 @@ from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models from capellacollab.users.tokens import routes as tokens_routes +from capellacollab.users.workspaces import routes as workspaces_routes +from capellacollab.users.workspaces import util as workspaces_util from . import crud, exceptions, injectables, models @@ -164,6 +166,7 @@ def delete_user( ): projects_users_crud.delete_projects_for_user(db, user.id) events_crud.delete_all_events_user_involved_in(db, user.id) + workspaces_util.delete_all_workspaces_of_user(db, user) crud.delete_user(db, user) @@ -196,6 +199,11 @@ def get_projects_for_user( router.include_router(session_routes.users_router, tags=["Users - Sessions"]) +router.include_router( + workspaces_routes.router, + prefix="/{user_id}/workspaces", + tags=["Users - Workspaces"], +) router.include_router( tokens_routes.router, prefix="/current/tokens", tags=["Users - Token"] ) diff --git a/backend/capellacollab/users/workspaces/__init__.py b/backend/capellacollab/users/workspaces/__init__.py new file mode 100644 index 000000000..04412280d --- /dev/null +++ b/backend/capellacollab/users/workspaces/__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/users/workspaces/crud.py b/backend/capellacollab/users/workspaces/crud.py new file mode 100644 index 000000000..7ce9a41d0 --- /dev/null +++ b/backend/capellacollab/users/workspaces/crud.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from collections import abc + +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.users import models as users_models + +from . import models + + +def get_workspaces_for_user( + db: orm.Session, user: users_models.DatabaseUser +) -> abc.Sequence[models.DatabaseWorkspace]: + return ( + db.execute( + sa.select(models.DatabaseWorkspace).where( + models.DatabaseWorkspace.user == user + ) + ) + .scalars() + .all() + ) + + +def get_workspace_by_id_and_user( + db: orm.Session, user: users_models.DatabaseUser, workspace_id: int +) -> models.DatabaseWorkspace | None: + return db.execute( + sa.select(models.DatabaseWorkspace).where( + models.DatabaseWorkspace.user == user, + models.DatabaseWorkspace.id == workspace_id, + ) + ).scalar_one_or_none() + + +def create_workspace( + db: orm.Session, + workspace: models.DatabaseWorkspace, +) -> models.DatabaseWorkspace: + db.add(workspace) + db.commit() + return workspace + + +def delete_workspace( + db: orm.Session, workspace: models.DatabaseWorkspace +) -> None: + db.delete(workspace) + db.commit() diff --git a/backend/capellacollab/users/workspaces/exceptions.py b/backend/capellacollab/users/workspaces/exceptions.py new file mode 100644 index 000000000..d6c198473 --- /dev/null +++ b/backend/capellacollab/users/workspaces/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 WorkspaceNotFound(core_exceptions.BaseError): + def __init__(self, username: str, workspace_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + title="Workspace not found", + reason=f"The workspace with ID {workspace_id} doesn't exist for user '{username}'.", + err_code="USER_WORKSPACE_NOT_FOUND", + ) diff --git a/backend/capellacollab/users/workspaces/injectables.py b/backend/capellacollab/users/workspaces/injectables.py new file mode 100644 index 000000000..841a76386 --- /dev/null +++ b/backend/capellacollab/users/workspaces/injectables.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi + +from capellacollab.core import database +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models + +from . import crud, exceptions, models + + +def get_existing_user_workspace( + workspace_id: int, + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_existing_user + ), + db=fastapi.Depends(database.get_db), +) -> models.DatabaseWorkspace: + if workspace := crud.get_workspace_by_id_and_user(db, user, workspace_id): + return workspace + + raise exceptions.WorkspaceNotFound( + username=user.name, workspace_id=workspace_id + ) diff --git a/backend/capellacollab/users/workspaces/models.py b/backend/capellacollab/users/workspaces/models.py new file mode 100644 index 000000000..22817f1cc --- /dev/null +++ b/backend/capellacollab/users/workspaces/models.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core import pydantic as core_pydantic +from capellacollab.users import models as users_models + + +class Workspace(core_pydantic.BaseModel): + id: int + pvc_name: str + size: str + + +class DatabaseWorkspace(database.Base): + __tablename__ = "workspaces" + + id: orm.Mapped[int] = orm.mapped_column( + init=False, primary_key=True, index=True, autoincrement=True + ) + + pvc_name: orm.Mapped[str] = orm.mapped_column(unique=True) + size: orm.Mapped[str] + + user_id: orm.Mapped[int] = orm.mapped_column( + sa.ForeignKey("users.id"), primary_key=True, init=False + ) + user: orm.Mapped[users_models.DatabaseUser] = orm.relationship() diff --git a/backend/capellacollab/users/workspaces/routes.py b/backend/capellacollab/users/workspaces/routes.py new file mode 100644 index 000000000..0a63de9d7 --- /dev/null +++ b/backend/capellacollab/users/workspaces/routes.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import typing as t + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database, responses +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.users import exceptions as users_exceptions +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models + +from . import crud, exceptions, injectables, models, util + +router = fastapi.APIRouter( + dependencies=[ + fastapi.Depends( + auth_injectables.RoleVerification( + required_role=users_models.Role.ADMIN + ) + ) + ] +) + + +@router.get( + "", + response_model=list[models.Workspace], +) +def get_workspaces_for_user( + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_existing_user + ), + db: orm.Session = fastapi.Depends(database.get_db), +) -> t.Sequence[models.DatabaseWorkspace]: + return crud.get_workspaces_for_user(db=db, user=user) + + +@router.delete( + "/{workspace_id}", + status_code=204, + responses=responses.api_exceptions( + [ + exceptions.WorkspaceNotFound("test", 0), + users_exceptions.UserNotFoundError("test"), + ] + ), +) +def delete_workspace( + workspace: models.DatabaseWorkspace = fastapi.Depends( + injectables.get_existing_user_workspace + ), + db: orm.Session = fastapi.Depends(database.get_db), +) -> None: + util.delete_workspace(db, workspace) diff --git a/backend/capellacollab/users/workspaces/util.py b/backend/capellacollab/users/workspaces/util.py new file mode 100644 index 000000000..d9d9745db --- /dev/null +++ b/backend/capellacollab/users/workspaces/util.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from sqlalchemy import orm + +from capellacollab.core import exceptions as core_exceptions +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions import operators +from capellacollab.users import models as users_models + +from . import crud, models + + +def delete_all_workspaces_of_user( + db: orm.Session, user: users_models.DatabaseUser +): + for workspace in crud.get_workspaces_for_user(db=db, user=user): + delete_workspace(db, workspace) + + +def delete_workspace(db: orm.Session, workspace: models.DatabaseWorkspace): + persistent_sessions_of_user = [ + session + for session in workspace.user.sessions + if session.type == sessions_models.SessionType.PERSISTENT + ] + if persistent_sessions_of_user: + raise core_exceptions.ExistingDependenciesError( + workspace.pvc_name, + "workspace", + [ + f"Session {session.id}" + for session in persistent_sessions_of_user + ], + ) + operators.get_operator().delete_persistent_volume(workspace.pvc_name) + crud.delete_workspace(db, workspace) diff --git a/backend/tests/cli/test_workspace_backup.py b/backend/tests/cli/test_workspace_backup.py index b3b3b703c..42c47eebc 100644 --- a/backend/tests/cli/test_workspace_backup.py +++ b/backend/tests/cli/test_workspace_backup.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 import datetime +import json +import pathlib import tarfile import kubernetes.client @@ -18,7 +20,7 @@ def mock_kube_config(monkeypatch: pytest.MonkeyPatch): @pytest.fixture(autouse=True) -def mock_core_v1_api(monkeypatch): +def mock_core_v1_api(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "kubernetes.client.CoreV1Api.create_namespaced_pod", lambda self, ns, pod: pod, @@ -35,37 +37,59 @@ def mock_core_v1_api(monkeypatch): ) monkeypatch.setattr( "kubernetes.client.CoreV1Api.create_namespaced_persistent_volume_claim", - lambda self, ns, vpc: None, + lambda self, ns, pvc: None, ) -def test_workspace_volumes(monkeypatch, capsys): +def test_workspace_volumes( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +): + def pvc_factory( + name: str, labels: dict[str, str] | None = None + ) -> kubernetes.client.V1PersistentVolumeClaim: + return kubernetes.client.V1PersistentVolumeClaim( + metadata=kubernetes.client.V1ObjectMeta( + name=name, + labels=labels, + creation_timestamp=datetime.datetime.now(datetime.UTC), + ), + spec=kubernetes.client.V1PersistentVolumeClaimSpec( + access_modes=["ReadWriteOnce"], + resources=kubernetes.client.V1ResourceRequirements( + requests={"storage": "1Gi"} + ), + ), + ) + + pvcs = [ + pvc_factory("workspace-test2"), + pvc_factory("persistent-session-test"), # Legacy PVC names + pvc_factory("shared-workspace-project-test"), + pvc_factory( + "shared-workspace-2342352342", + labels={"capellacollab/project_slug": "project-test"}, + ), + pvc_factory("my-volume"), + ] + monkeypatch.setattr( "kubernetes.client.CoreV1Api.list_namespaced_persistent_volume_claim", lambda self, namespace, watch: kubernetes.client.V1PersistentVolumeClaimList( - items=[ - kubernetes.client.V1PersistentVolumeClaim( - metadata=kubernetes.client.V1ObjectMeta( - name="my-volume", - creation_timestamp=datetime.datetime.now(datetime.UTC), - ), - spec=kubernetes.client.V1PersistentVolumeClaimSpec( - access_modes=["ReadWriteOnce"], - resources=kubernetes.client.V1ResourceRequirements( - requests={"storage": "1Gi"} - ), - ), - ) - ] + items=pvcs ), ) volumes(namespace="default") - assert "my-volume" in capsys.readouterr().out + stdout = capsys.readouterr().out + for pvc in pvcs: + assert pvc.metadata.name in stdout -def test_ls_workspace(monkeypatch, tmp_path, capsys): +def test_ls_workspace( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +): mock_stream = MockWSClient([b"\01hello"]) monkeypatch.setattr( "kubernetes.stream.stream", lambda *a, **ka: mock_stream @@ -77,19 +101,68 @@ def test_ls_workspace(monkeypatch, tmp_path, capsys): assert "hello" in capsys.readouterr().out -def test_backup_workspace(monkeypatch, tmp_path): +def test_backup_workspace( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +): mock_stream = MockWSClient([b"\01hello"]) monkeypatch.setattr( "kubernetes.stream.stream", lambda *a, **ka: mock_stream ) monkeypatch.setattr("select.select", lambda *a: (1, None, None)) - backup("my-volume-name", "my-namespace", tmp_path) + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "read_namespaced_persistent_volume_claim", + lambda self, name, namespace: kubernetes.client.V1PersistentVolumeClaim( + spec=kubernetes.client.V1PersistentVolumeClaimSpec( + resources=kubernetes.client.V1ResourceRequirements( + requests={"storage": "1Gi"}, + ), + ), + metadata=kubernetes.client.V1ObjectMeta( + annotations={ + "capellacollab/username": "test", + "capellacollab/user-id": str(10), + "capellacollab/volume": "personal-workspace", + }, + ), + ), + ) + backup("my-volume-name", "my-namespace", tmp_path) assert (tmp_path / "my-volume-name.tar.gz").exists() + sidecar_path = tmp_path / "my-volume-name.json" + assert sidecar_path.exists() + + sidecar = json.loads(sidecar_path.read_text()) + + assert sidecar.get("size") == "1Gi" + assert sidecar.get("annotations").get("capellacollab/username") == "test" + + +def test_restore_workspace_without_sidecar( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +): + pvc_created = False + + def mock_create_namespaced_persistent_volume_claim( + self, namespace: str, pvc: kubernetes.client.V1PersistentVolumeClaim + ): + assert namespace == "default" + assert pvc.metadata.name == "my-volume-name" + assert pvc.spec.resources.requests["storage"] == "20Gi" + assert not pvc.metadata.annotations + + nonlocal pvc_created + pvc_created = True + + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "create_namespaced_persistent_volume_claim", + mock_create_namespaced_persistent_volume_claim, + ) -def test_restore_workspace(monkeypatch, tmp_path, capsys): mock_stream = MockWSClient([]) monkeypatch.setattr( "kubernetes.stream.stream", lambda *a, **ka: mock_stream @@ -102,6 +175,49 @@ def test_restore_workspace(monkeypatch, tmp_path, capsys): restore("my-volume-name", tar_path, "default") assert mock_stream.written_data + assert pvc_created + + +def test_restore_workspace_with_sidecar( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +): + pvc_created = False + + sidecar_path = tmp_path / "backup.json" + sidecar_path.write_text( + json.dumps({"size": "1Gi", "annotations": {"foo": "bar"}}) + ) + + def mock_create_namespaced_persistent_volume_claim( + self, namespace: str, pvc: kubernetes.client.V1PersistentVolumeClaim + ): + assert namespace == "default" + assert pvc.metadata.name == "my-volume-name" + assert pvc.spec.resources.requests["storage"] == "1Gi" + assert pvc.metadata.annotations["foo"] == "bar" + + nonlocal pvc_created + pvc_created = True + + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "create_namespaced_persistent_volume_claim", + mock_create_namespaced_persistent_volume_claim, + ) + + mock_stream = MockWSClient([]) + monkeypatch.setattr( + "kubernetes.stream.stream", lambda *a, **ka: mock_stream + ) + + tar_path = tmp_path / "backup.tar.gz" + with tarfile.open(tar_path, "w:gz") as tar: + tar.add("tests") + + restore("my-volume-name", tar_path, "default", sidecar_path) + + assert mock_stream.written_data + assert pvc_created class MockWSClient: diff --git a/backend/tests/sessions/fixtures.py b/backend/tests/sessions/fixtures.py index 45efab586..bd880019e 100644 --- a/backend/tests/sessions/fixtures.py +++ b/backend/tests/sessions/fixtures.py @@ -23,7 +23,7 @@ def fixture_session( tool_version: tools_models.DatabaseVersion, ) -> sessions_models.DatabaseSession: session = sessions_models.DatabaseSession( - str(uuid.uuid1()), + id=str(uuid.uuid1()), created_at=datetime.datetime.now(), type=sessions_models.SessionType.PERSISTENT, environment={"CAPELLACOLLAB_SESSION_TOKEN": "thisisarandomtoken"}, @@ -35,6 +35,26 @@ def fixture_session( return sessions_crud.create_session(db, session) +@pytest.fixture(name="test_session") +def fixture_test_session( + db: orm.Session, + test_user: users_models.DatabaseUser, + tool: tools_models.DatabaseTool, + tool_version: tools_models.DatabaseVersion, +) -> sessions_models.DatabaseSession: + session = sessions_models.DatabaseSession( + id=str(uuid.uuid1()), + created_at=datetime.datetime.now(), + type=sessions_models.SessionType.PERSISTENT, + environment={"CAPELLACOLLAB_SESSION_TOKEN": "thisisarandomtoken"}, + owner=test_user, + tool=tool, + version=tool_version, + connection_method_id=tool.config.connection.methods[0].id, + ) + return sessions_crud.create_session(db, session) + + @pytest.fixture(name="mock_session_injection") def fixture_mock_session_injection(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( diff --git a/backend/tests/sessions/hooks/test_persistent_workspace.py b/backend/tests/sessions/hooks/test_persistent_workspace.py index b47227026..c336e54e6 100644 --- a/backend/tests/sessions/hooks/test_persistent_workspace.py +++ b/backend/tests/sessions/hooks/test_persistent_workspace.py @@ -1,26 +1,131 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import kubernetes import pytest +from kubernetes.client import exceptions +from sqlalchemy import orm from capellacollab.sessions import exceptions as sessions_exceptions from capellacollab.sessions import models as sessions_models from capellacollab.sessions import operators +from capellacollab.sessions.hooks import interface as hooks_interface from capellacollab.sessions.hooks import persistent_workspace from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models +from capellacollab.users.workspaces import crud as users_workspaces_crud +from capellacollab.users.workspaces import models as users_workspaces_models def test_persistent_workspace_mounting_not_allowed( + db: orm.Session, tool: tools_models.DatabaseTool, - user: users_models.DatabaseUser, + test_user: users_models.DatabaseUser, ): tool.config.persistent_workspaces.mounting_enabled = False with pytest.raises(sessions_exceptions.WorkspaceMountingNotAllowedError): persistent_workspace.PersistentWorkspaceHook().configuration_hook( + db=db, operator=operators.KubernetesOperator(), - user=user, + user=test_user, session_type=sessions_models.SessionType.PERSISTENT, tool=tool, ) + + +def persistent_workspace_mounting_readonly_session( + db: orm.Session, + tool: tools_models.DatabaseTool, + test_user: users_models.DatabaseUser, +): + response = ( + persistent_workspace.PersistentWorkspaceHook().configuration_hook( + db=db, + operator=operators.KubernetesOperator(), + user=test_user, + session_type=sessions_models.SessionType.READONLY, + tool=tool, + ) + ) + + assert response == hooks_interface.ConfigurationHookResult() + + +def test_workspace_is_created( + db: orm.Session, + tool: tools_models.DatabaseTool, + test_user: users_models.DatabaseUser, + monkeypatch: pytest.MonkeyPatch, +): + created_volumes = 0 + volume_name = None + + def mock_create_namespaced_persistent_volume_claim( + self, ns: str, pvc: kubernetes.client.V1PersistentVolumeClaim + ): + nonlocal created_volumes, volume_name + created_volumes += 1 + volume_name = pvc.metadata.name + + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "create_namespaced_persistent_volume_claim", + mock_create_namespaced_persistent_volume_claim, + ) + + assert ( + len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 0 + ) + persistent_workspace.PersistentWorkspaceHook().configuration_hook( + db=db, + operator=operators.KubernetesOperator(), + user=test_user, + session_type=sessions_models.SessionType.PERSISTENT, + tool=tool, + ) + assert created_volumes == 1 + assert isinstance(volume_name, str) + assert volume_name.startswith("workspace-") + assert ( + len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 1 + ) + + +def test_existing_workspace_is_mounted( + db: orm.Session, + tool: tools_models.DatabaseTool, + test_user: users_models.DatabaseUser, + user_workspace: users_workspaces_models.DatabaseWorkspace, + monkeypatch: pytest.MonkeyPatch, +): + created_volumes = 0 + volume_name = None + + def mock_create_namespaced_persistent_volume_claim(self, ns, pvc): + nonlocal created_volumes, volume_name + created_volumes += 1 + volume_name = pvc.metadata.name + raise exceptions.ApiException(status=409) + + monkeypatch.setattr( + "kubernetes.client.CoreV1Api.create_namespaced_persistent_volume_claim", + mock_create_namespaced_persistent_volume_claim, + ) + + assert ( + len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 1 + ) + persistent_workspace.PersistentWorkspaceHook().configuration_hook( + db=db, + operator=operators.KubernetesOperator(), + user=test_user, + session_type=sessions_models.SessionType.PERSISTENT, + tool=tool, + ) + assert created_volumes == 1 + assert isinstance(volume_name, str) + assert volume_name == user_workspace.pvc_name + assert ( + len(users_workspaces_crud.get_workspaces_for_user(db, test_user)) == 1 + ) diff --git a/backend/tests/users/fixtures.py b/backend/tests/users/fixtures.py index a7220650c..03bf2579e 100644 --- a/backend/tests/users/fixtures.py +++ b/backend/tests/users/fixtures.py @@ -14,6 +14,8 @@ from capellacollab.users import crud as users_crud from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models +from capellacollab.users.workspaces import crud as users_workspaces_crud +from capellacollab.users.workspaces import models as users_workspaces_models @pytest.fixture(name="executor_name") @@ -69,3 +71,20 @@ def get_mock_own_user(): ) yield admin del app.dependency_overrides[users_injectables.get_own_user] + + +@pytest.fixture(name="test_user") +def fixture_test_user(db: orm.Session) -> users_models.DatabaseUser: + return users_crud.create_user(db, "testuser", users_models.Role.USER) + + +@pytest.fixture(name="user_workspace") +def fixture_user_workspace( + db: orm.Session, test_user: users_models.DatabaseUser +) -> users_workspaces_models.DatabaseWorkspace: + return users_workspaces_crud.create_workspace( + db, + users_workspaces_models.DatabaseWorkspace( + "mock-workspace", "20Gi", test_user + ), + ) diff --git a/backend/tests/users/test_users.py b/backend/tests/users/test_users.py index 231710a1a..e99ee9c4e 100644 --- a/backend/tests/users/test_users.py +++ b/backend/tests/users/test_users.py @@ -1,15 +1,20 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import kubernetes import pytest from fastapi import testclient from sqlalchemy import orm import capellacollab.projects.users.models as projects_users_models +from capellacollab.events import crud as events_crud +from capellacollab.events import models as events_models from capellacollab.projects import models as projects_models from capellacollab.projects.users import crud as projects_users_crud from capellacollab.users import crud as users_crud from capellacollab.users import models as users_models +from capellacollab.users.workspaces import crud as user_workspace_crud +from capellacollab.users.workspaces import models as user_workspace_models def test_get_user_by_id_admin( @@ -25,7 +30,7 @@ def test_get_user_by_id_admin( @pytest.mark.usefixtures("user") def test_get_user_by_id_non_admin( - client: testclient.TestClient, db: orm.Session, executor_name: str + client: testclient.TestClient, db: orm.Session ): user = users_crud.create_user(db, "test_user") response = client.get(f"/api/v1/users/{user.id}") @@ -82,3 +87,41 @@ def test_get_common_projects( assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["slug"] == project.slug + + +@pytest.mark.usefixtures("admin") +def test_delete_user( + client: testclient.TestClient, + db: orm.Session, + admin: users_models.DatabaseUser, + test_user: users_models.DatabaseUser, + monkeypatch: pytest.MonkeyPatch, +): + event = events_crud.create_event( + db, + test_user, + events_models.EventType.CREATED_USER, + executor=admin, + ) + workspace = user_workspace_crud.create_workspace( + db, user_workspace_models.DatabaseWorkspace("test", "1Gi", test_user) + ) + + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "delete_namespaced_persistent_volume_claim", + lambda self, name, namespace: kubernetes.client.V1Status(), + ) + + response = client.delete(f"/api/v1/users/{test_user.id}") + + assert response.status_code == 204 + assert users_crud.get_user_by_id(db, test_user.id) is None + with pytest.raises(orm.exc.ObjectDeletedError): + events_crud.get_event_by_id(db, event.id) + assert ( + user_workspace_crud.get_workspace_by_id_and_user( + db, test_user, workspace.id + ) + is None + ) diff --git a/backend/tests/users/test_workspaces.py b/backend/tests/users/test_workspaces.py new file mode 100644 index 000000000..e1e865d3b --- /dev/null +++ b/backend/tests/users/test_workspaces.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import kubernetes +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.users import models as users_models +from capellacollab.users.workspaces import crud as user_workspace_crud +from capellacollab.users.workspaces import models as user_workspace_models + + +@pytest.mark.usefixtures("admin") +def test_get_workspaces( + client: testclient.TestClient, + test_user: users_models.DatabaseUser, + user_workspace: user_workspace_models.DatabaseWorkspace, +): + response = client.get(f"/api/v1/users/{test_user.id}/workspaces") + assert len(response.json()) == 1 + assert response.json()[0]["pvc_name"] == user_workspace.pvc_name + + +@pytest.mark.usefixtures("admin") +def test_delete_workspaces_404( + client: testclient.TestClient, + test_user: users_models.DatabaseUser, +): + response = client.delete(f"/api/v1/users/{test_user.id}/workspaces/0") + assert response.status_code == 404 + assert response.json()["detail"]["err_code"] == "USER_WORKSPACE_NOT_FOUND" + + +@pytest.mark.usefixtures("admin") +def test_delete_workspace( + client: testclient.TestClient, + db: orm.Session, + test_user: users_models.DatabaseUser, + user_workspace: user_workspace_models.DatabaseWorkspace, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr( + kubernetes.client.CoreV1Api, + "delete_namespaced_persistent_volume_claim", + lambda self, name, namespace: kubernetes.client.V1Status(), + ) + + response = client.delete( + f"/api/v1/users/{test_user.id}/workspaces/{user_workspace.id}" + ) + + assert response.status_code == 204 + assert ( + user_workspace_crud.get_workspace_by_id_and_user( + db, test_user, user_workspace.id + ) + is None + ) + + +@pytest.mark.usefixtures("admin", "test_session") +def test_delete_workspace_with_open_sessions( + client: testclient.TestClient, + test_user: users_models.DatabaseUser, + user_workspace: user_workspace_models.DatabaseWorkspace, +): + response = client.delete( + f"/api/v1/users/{test_user.id}/workspaces/{user_workspace.id}" + ) + + assert response.status_code == 409 + assert ( + response.json()["detail"]["err_code"] + == "EXISTING_DEPENDENCIES_PREVENT_DELETE" + ) diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 774543a97..baa739105 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -457,9 +457,20 @@ export const routes: Routes = [ component: EventsComponent, }, { - path: 'user/:userId', - data: { breadcrumb: (data: Data) => data?.user?.name || 'User' }, - component: UsersProfileComponent, + path: 'user', + data: { breadcrumb: 'User' }, + children: [ + { + path: '', + data: { breadcrumb: undefined }, + component: UserSettingsComponent, + }, + { + path: ':userId', + data: { breadcrumb: (data: Data) => data?.user?.name || 'User' }, + component: UsersProfileComponent, + }, + ], }, { path: 'tokens', diff --git a/frontend/src/app/helpers/confirmation-dialog/confirmation-dialog.component.html b/frontend/src/app/helpers/confirmation-dialog/confirmation-dialog.component.html index 533a89495..39a38fc5a 100644 --- a/frontend/src/app/helpers/confirmation-dialog/confirmation-dialog.component.html +++ b/frontend/src/app/helpers/confirmation-dialog/confirmation-dialog.component.html @@ -6,23 +6,25 @@

{{ data.title }}

-

{{ data.text }}

-

- Please type in {{ data.requiredInput }} to - confirm the action: -

- - - +
+ @if (data.requiredInput) { +

+ Please type in + {{ data.requiredInput }} to confirm the + action: +

+ + + + } @else { +
+ } +
+
+
+ } + } + + +} diff --git a/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.component.ts b/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.component.ts new file mode 100644 index 000000000..7021e259a --- /dev/null +++ b/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.component.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { ConfirmationDialogComponent } from 'src/app/helpers/confirmation-dialog/confirmation-dialog.component'; +import { DisplayValueComponent } from 'src/app/helpers/display-value/display-value.component'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { User, UsersService, Workspace } from 'src/app/openapi'; +import { UserWrapperService } from 'src/app/services/user/user.service'; +@Component({ + selector: 'app-user-workspaces', + standalone: true, + imports: [ + CommonModule, + MatDividerModule, + DisplayValueComponent, + MatButtonModule, + MatIconModule, + ], + templateUrl: './user-workspaces.component.html', + styles: ` + :host { + display: block; + } + `, +}) +export class UserWorkspacesComponent { + _user: User | undefined; + + workspaces: Workspace[] | undefined = undefined; + + @Input() + set user(value: User | undefined) { + this._user = value; + this.reloadWorkspaces(); + } + + reloadWorkspaces() { + this.workspaces = undefined; + if (this._user === undefined) return; + + this.usersService.getWorkspacesForUser(this._user.id).subscribe({ + next: (workspaces) => { + this.workspaces = workspaces; + }, + error: () => (this.workspaces = undefined), + }); + } + + get user(): User | undefined { + return this._user; + } + + constructor( + public userService: UserWrapperService, + private usersService: UsersService, + private dialog: MatDialog, + private toastService: ToastService, + ) {} + + deleteWorkspace(workspace: Workspace) { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Delete workspace', + text: + `Do you really want to delete the workspace ${workspace.id} of user '${this._user!.name}'? ` + + 'This will irrevocably remove all files in the workspace.', + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.usersService + .deleteWorkspace(workspace.id, this._user!.id) + .subscribe({ + next: () => { + this.toastService.showSuccess( + 'Workspace deleted successfully.', + `The workspace ${workspace.id} of user '${this._user!.name}' was deleted.`, + ); + + this.reloadWorkspaces(); + }, + }); + } + }); + } +} diff --git a/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.stories.ts b/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.stories.ts new file mode 100644 index 000000000..7c747b6ef --- /dev/null +++ b/frontend/src/app/users/users-profile/user-workspaces/user-workspaces.stories.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { UserWrapperService } from 'src/app/services/user/user.service'; +import { mockUser, MockUserService } from 'src/storybook/user'; +import { UserWorkspacesComponent } from './user-workspaces.component'; + +const meta: Meta = { + title: 'Settings Components / Users Profile / User Workspaces', + component: UserWorkspacesComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserWrapperService, + useFactory: () => + new MockUserService({ ...mockUser, role: 'administrator' }), + }, + ], + }), + ], + args: { + _user: { ...mockUser, id: 0 }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: {}, +}; + +export const NoWorkspaces: Story = { + args: { + workspaces: [], + }, +}; + +export const Workspace: Story = { + args: { + workspaces: [ + { + id: 1, + pvc_name: 'persistent-volume-429d805a-6904-4217-b035-8e3def3506ce', + size: '20Gi', + }, + ], + }, +}; diff --git a/frontend/src/app/users/users-profile/users-profile.component.css b/frontend/src/app/users/users-profile/users-profile.component.css deleted file mode 100644 index c41dbe360..000000000 --- a/frontend/src/app/users/users-profile/users-profile.component.css +++ /dev/null @@ -1,9 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.mat-cell, -.mat-header-cell { - padding: 8px 8px 8px 0; -} diff --git a/frontend/src/app/users/users-profile/users-profile.component.html b/frontend/src/app/users/users-profile/users-profile.component.html index 8b340cb47..bb56979c0 100644 --- a/frontend/src/app/users/users-profile/users-profile.component.html +++ b/frontend/src/app/users/users-profile/users-profile.component.html @@ -2,7 +2,7 @@ ~ SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors ~ SPDX-License-Identifier: Apache-2.0 --> -
+

Profile of {{ user?.name }}

-
- Joined the Capella Collaboration Manager in - {{ user?.created | date: "y" }} -
-
-
-
-
-

User information

- This section is only visible to administrators. -
- Last login: - {{ user?.last_login | date: "EE, dd MMM y HH:mm:ss" }} -
-
+ @if (user?.created) {
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Events related to the user -
Event Type{{ event.event_type }}Executor Name - {{ event.executor?.name || "System" }} - Execution Time - {{ event.execution_time | date: "EE, dd MMM y HH:mm:ss" }} - Project Slug - {{ event.project?.name || "" }} - Reason{{ event.reason }}
+ Joined the Capella Collaboration Manager in + {{ user?.created | date: "y" }}
-
-
- -
- -
- - -
+ }
-
-
-
-
-

Common Projects

- -
- @if ((commonProjects$ | async) === undefined) { - Loading... - } @else if ((commonProjects$ | async)?.length !== 0) { -
-
- {{ project.name }}
- @if (project.description) { - {{ - project.description - }} - } @else { - No description provided - } -
-
- } @else { - You do not have any common projects. - } -
-
-
-
+ +
+ + +
diff --git a/frontend/src/app/users/users-profile/users-profile.component.ts b/frontend/src/app/users/users-profile/users-profile.component.ts index 4edf7288f..ac9952d57 100644 --- a/frontend/src/app/users/users-profile/users-profile.component.ts +++ b/frontend/src/app/users/users-profile/users-profile.component.ts @@ -3,81 +3,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NgIf, NgFor, AsyncPipe, DatePipe } from '@angular/common'; -import { - AfterViewInit, - Component, - OnDestroy, - OnInit, - ViewChild, -} from '@angular/core'; -import { MatDivider } from '@angular/material/divider'; -import { MatPaginator } from '@angular/material/paginator'; -import { - MatTableDataSource, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, -} from '@angular/material/table'; +import { DatePipe } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; + import { ActivatedRoute, RouterLink } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { BehaviorSubject, filter, map } from 'rxjs'; +import { filter, map } from 'rxjs'; import { BreadcrumbsService } from 'src/app/general/breadcrumbs/breadcrumbs.service'; -import { HistoryEvent, Project, User, UsersService } from 'src/app/openapi'; +import { User, UsersService } from 'src/app/openapi'; import { UserWrapperService } from 'src/app/services/user/user.service'; +import { CommonProjectsComponent } from './common-projects/common-projects.component'; +import { UserInformationComponent } from './user-information/user-information.component'; +import { UserWorkspacesComponent } from './user-workspaces/user-workspaces.component'; @UntilDestroy() @Component({ selector: 'app-users-profile', templateUrl: './users-profile.component.html', - styleUrls: ['./users-profile.component.css'], standalone: true, imports: [ - NgIf, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - NgxSkeletonLoaderModule, - MatPaginator, - MatDivider, - NgFor, RouterLink, - AsyncPipe, DatePipe, + CommonProjectsComponent, + UserInformationComponent, + UserWorkspacesComponent, ], }) -export class UsersProfileComponent implements OnInit, OnDestroy, AfterViewInit { - @ViewChild(MatPaginator) paginator: MatPaginator | null = null; - displayedColumns: string[] = [ - 'eventType', - 'executorName', - 'executionTime', - 'projectName', - 'reason', - ]; - +export class UsersProfileComponent implements OnInit, OnDestroy { user: User | undefined; - commonProjects = new BehaviorSubject(undefined); - userEvents?: HistoryEvent[]; - - historyEventDataSource = new MatTableDataSource([]); - - public readonly commonProjects$ = this.commonProjects.asObservable(); constructor( public userService: UserWrapperService, @@ -96,30 +49,10 @@ export class UsersProfileComponent implements OnInit, OnDestroy, AfterViewInit { this.usersService.getUser(userId).subscribe((user) => { this.user = user; this.breadcrumbsService.updatePlaceholder({ user: user }); - if (userId !== this.userService.user?.id) { - this.usersService.getCommonProjects(userId).subscribe({ - next: (projects) => this.commonProjects.next(projects), - error: () => this.commonProjects.next(undefined), - }); - } - - if (this.userService.user?.role === 'administrator') { - this.usersService.getUserEvents(userId).subscribe({ - next: (userEvents) => { - this.userEvents = userEvents; - this.historyEventDataSource.data = userEvents; - this.historyEventDataSource.paginator = this.paginator; - }, - }); - } }); }); } - ngAfterViewInit(): void { - this.historyEventDataSource.paginator = this.paginator; - } - ngOnDestroy(): void { this.breadcrumbsService.updatePlaceholder({ user: undefined }); }