From 7d8eb73789982a6bd1c29afda117a71b72cd133c Mon Sep 17 00:00:00 2001 From: Paula-Kli Date: Thu, 28 Sep 2023 15:47:17 +0200 Subject: [PATCH] feat: Move model from one project to another Users might want to move a toolmodel from one project to another without deleting it and all its pipeline before adding it again. This is possible with one simple click. --- backend/capellacollab/projects/routes.py | 22 +++-- .../capellacollab/projects/toolmodels/crud.py | 2 + .../projects/toolmodels/models.py | 5 +- .../projects/toolmodels/routes.py | 71 +++++++++++++- .../tests/projects/test_projects_routes.py | 32 +++++++ .../projects/toolmodels/test_toolmodels.py | 59 ++++++++++++ frontend/src/app/app.module.ts | 2 + .../model-description.component.ts | 2 +- .../projects/models/service/model.service.ts | 20 +++- .../model-overview.component.html | 9 ++ .../model-overview.component.ts | 9 ++ .../move-model/move-model.component.css | 4 + .../move-model/move-model.component.html | 44 +++++++++ .../move-model/move-model.component.ts | 96 +++++++++++++++++++ .../app/projects/service/project.service.ts | 9 ++ 15 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 backend/tests/projects/toolmodels/test_toolmodels.py create mode 100644 frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.css create mode 100644 frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.html create mode 100644 frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.ts diff --git a/backend/capellacollab/projects/routes.py b/backend/capellacollab/projects/routes.py index 34f703d1ef..3a80e0bd12 100644 --- a/backend/capellacollab/projects/routes.py +++ b/backend/capellacollab/projects/routes.py @@ -42,6 +42,7 @@ @router.get("", response_model=list[models.Project], tags=["Projects"]) def get_projects( + minimum_role: projects_users_models.ProjectUserRole | None = None, user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user ), @@ -57,12 +58,21 @@ def get_projects( log.debug("Fetching all projects") return list(crud.get_projects(db)) - projects = [ - association.project - for association in user.projects - if not association.project.visibility == models.Visibility.INTERNAL - ] - projects.extend(crud.get_internal_projects(db)) + if not minimum_role: + projects = [ + association.project + for association in user.projects + if not association.project.visibility == models.Visibility.INTERNAL + ] + projects.extend(crud.get_internal_projects(db)) + else: + projects = [ + association.project + for association in user.projects + if auth_injectables.ProjectRoleVerification( + minimum_role, verify=False + )(association.project.slug, username, db) + ] log.debug("Fetching the following projects: %s", projects) return projects diff --git a/backend/capellacollab/projects/toolmodels/crud.py b/backend/capellacollab/projects/toolmodels/crud.py index 8032f945ab..81e8cfd6f2 100644 --- a/backend/capellacollab/projects/toolmodels/crud.py +++ b/backend/capellacollab/projects/toolmodels/crud.py @@ -131,9 +131,11 @@ def update_model( description: str | None, version: tools_models.DatabaseVersion, nature: tools_models.DatabaseNature, + project: projects_model.DatabaseProject, ) -> models.DatabaseCapellaModel: model.version = version model.nature = nature + model.project = project if description: model.description = description db.commit() diff --git a/backend/capellacollab/projects/toolmodels/models.py b/backend/capellacollab/projects/toolmodels/models.py index 0ad5badbe1..daa1205f80 100644 --- a/backend/capellacollab/projects/toolmodels/models.py +++ b/backend/capellacollab/projects/toolmodels/models.py @@ -50,8 +50,9 @@ class PostCapellaModel(pydantic.BaseModel): class PatchCapellaModel(pydantic.BaseModel): description: str | None = None - version_id: int - nature_id: int + version_id: int | None = None + nature_id: int | None = None + project_slug: str | None = None class ToolDetails(pydantic.BaseModel): diff --git a/backend/capellacollab/projects/toolmodels/routes.py b/backend/capellacollab/projects/toolmodels/routes.py index 069b707ba8..cb26fb9615 100644 --- a/backend/capellacollab/projects/toolmodels/routes.py +++ b/backend/capellacollab/projects/toolmodels/routes.py @@ -16,6 +16,8 @@ from capellacollab.tools import crud as tools_crud from capellacollab.tools import injectables as tools_injectables from capellacollab.tools import models as tools_models +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models from . import crud, injectables, models, workspace from .backups import routes as backups_routes @@ -125,9 +127,16 @@ def patch_tool_model( injectables.get_existing_capella_model ), db: orm.Session = fastapi.Depends(database.get_db), + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), ) -> models.DatabaseCapellaModel: - version = get_version_by_id_or_raise(db, body.version_id) - if version.tool != model.tool: + version = ( + get_version_by_id_or_raise(db, body.version_id) + if body.version_id + else model.version + ) + if body.version_id and version.tool != model.tool: raise fastapi.HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ @@ -135,8 +144,12 @@ def patch_tool_model( }, ) - nature = get_nature_by_id_or_raise(db, body.nature_id) - if nature.tool != model.tool: + nature = ( + get_nature_by_id_or_raise(db, body.nature_id) + if body.nature_id + else model.nature + ) + if body.nature_id is not None and nature.tool != model.tool: raise fastapi.HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ @@ -144,7 +157,17 @@ def patch_tool_model( }, ) - return crud.update_model(db, model, body.description, version, nature) + if body.project_slug: + new_project = determine_new_project_to_move_model( + body.project_slug, db, user + ) + raise_if_model_exists_in_project(model, new_project) + else: + new_project = model.project + + return crud.update_model( + db, model, body.description, version, nature, new_project + ) @router.delete( @@ -216,6 +239,44 @@ def get_nature_by_id_or_raise( ) +def determine_new_project_to_move_model( + project_slug: str, db: orm.Session, user: users_models.DatabaseUser +) -> projects_models.DatabaseProject: + new_project = projects_injectables.get_existing_project(project_slug, db) + success = user.role == users_models.Role.ADMIN + for association in user.projects: + if association.project_id == new_project.id: + if ( + not association.role + == projects_users_models.ProjectUserRole.MANAGER + ): + break + else: + success = True + + if not success: + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "reason": f"Requesting user does not have permission to move toolmodel to {new_project.slug}" + }, + ) + return new_project + + +def raise_if_model_exists_in_project( + model: models.DatabaseCapellaModel, + project: projects_models.DatabaseProject, +): + if model.slug in [model.slug for model in project.models]: + raise fastapi.HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "reason": f"Model with name {model.name} already exists in project {project.slug}" + }, + ) + + router.include_router( modelsources_routes.router, prefix="/{model_slug}/modelsources", diff --git a/backend/tests/projects/test_projects_routes.py b/backend/tests/projects/test_projects_routes.py index b3cbaa0e15..a72d772097 100644 --- a/backend/tests/projects/test_projects_routes.py +++ b/backend/tests/projects/test_projects_routes.py @@ -275,3 +275,35 @@ def test_delete_pipeline_called_when_archiving_project( mock_delete_pipeline.assert_called_once_with( db, mock_pipeline, mock.ANY, True ) + + + +@pytest.mark.usefixtures("project_user") +def test_get_project_per_role_user(client: testclient.TestClient): + response = client.get("/api/v1/projects/?minimum_role=user") + assert response.status_code == 200 + assert len(response.json()) > 0 + + +@pytest.mark.usefixtures("project_user") +def test_get_project_per_role_manager_as_user(client: testclient.TestClient): + response = client.get("/api/v1/projects/?minimum_role=manager") + assert response.status_code == 200 + assert len(response.json()) == 0 + + +@pytest.mark.usefixtures("project_manager") +def test_get_project_per_role_manager(client: testclient.TestClient): + response = client.get("/api/v1/projects/?minimum_role=manager") + assert response.status_code == 200 + assert len(response.json()) > 0 + + +def test_get_project_per_role_admin( + client: testclient.TestClient, executor_name: str, db: orm.Session +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + response = client.get("/api/v1/projects/?minimum_role=administrator") + assert response.status_code == 200 + assert len(response.json()) > 0 diff --git a/backend/tests/projects/toolmodels/test_toolmodels.py b/backend/tests/projects/toolmodels/test_toolmodels.py new file mode 100644 index 0000000000..01b4fd98c4 --- /dev/null +++ b/backend/tests/projects/toolmodels/test_toolmodels.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +from uuid import uuid4 + +import pytest +from fastapi import testclient +from sqlalchemy import orm + +import capellacollab.projects.users.crud as projects_users_crud +import capellacollab.projects.users.models as projects_users_models +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.users import models as users_models + + +def test_move_toolmodel( + project: projects_models.DatabaseProject, + project_manager: users_models.DatabaseUser, + capella_model: toolmodels_models.CapellaModel, + client: testclient.TestClient, + db: orm.Session, +): + second_project = projects_crud.create_project(db, str(uuid4())) + projects_users_crud.add_user_to_project( + db, + project=second_project, + user=project_manager, + role=projects_users_models.ProjectUserRole.MANAGER, + permission=projects_users_models.ProjectUserPermission.WRITE, + ) + + response = client.patch( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}", + json={"project_slug": second_project.slug}, + ) + assert response.status_code == 200 + + response = client.get( + f"/api/v1/projects/{second_project.slug}/models/{capella_model.slug}" + ) + assert response.status_code == 200 + + +@pytest.mark.usefixtures("project_manager") +def test_move_toolmodel_non_project_member( + project: projects_models.DatabaseProject, + capella_model: toolmodels_models.CapellaModel, + client: testclient.TestClient, + db: orm.Session, +): + second_project = projects_crud.create_project(db, str(uuid4())) + + response = client.patch( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}", + json={"project_slug": second_project.slug}, + ) + assert response.status_code == 401 diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 8715ca6a66..a1d16c88cd 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -95,6 +95,7 @@ import { ModelWrapperComponent } from './projects/models/model-wrapper/model-wra import { EditProjectMetadataComponent } from './projects/project-detail/edit-project-metadata/edit-project-metadata.component'; import { ModelComplexityBadgeComponent } from './projects/project-detail/model-overview/model-complexity-badge/model-complexity-badge.component'; import { ModelOverviewComponent } from './projects/project-detail/model-overview/model-overview.component'; +import { MoveModelComponent } from './projects/project-detail/model-overview/move-model/move-model.component'; import { ProjectDetailsComponent } from './projects/project-detail/project-details.component'; import { ProjectMetadataComponent } from './projects/project-detail/project-metadata/project-metadata.component'; import { AddUserToProjectDialogComponent } from './projects/project-detail/project-users/add-user-to-project/add-user-to-project.component'; @@ -192,6 +193,7 @@ import { SettingsComponent } from './settings/settings.component'; ModelOverviewComponent, ModelRestrictionsComponent, ModelWrapperComponent, + MoveModelComponent, NavBarMenuComponent, NoticeComponent, PipelineRunWrapperComponent, diff --git a/frontend/src/app/projects/models/model-description/model-description.component.ts b/frontend/src/app/projects/models/model-description/model-description.component.ts index 1a70fc903f..357c70469d 100644 --- a/frontend/src/app/projects/models/model-description/model-description.component.ts +++ b/frontend/src/app/projects/models/model-description/model-description.component.ts @@ -83,7 +83,7 @@ export class ModelDescriptionComponent implements OnInit { onSubmit(): void { if (this.form.value && this.modelSlug && this.projectSlug) { this.modelService - .updateModelDescription(this.projectSlug!, this.modelSlug!, { + .updateModel(this.projectSlug!, this.modelSlug!, { description: this.form.value.description || '', nature_id: this.form.value.nature || undefined, version_id: this.form.value.version || undefined, diff --git a/frontend/src/app/projects/models/service/model.service.ts b/frontend/src/app/projects/models/service/model.service.ts index 1ebf2a121f..0f6589b58c 100644 --- a/frontend/src/app/projects/models/service/model.service.ts +++ b/frontend/src/app/projects/models/service/model.service.ts @@ -92,7 +92,7 @@ export class ModelService { ); } - updateModelDescription( + updateModel( projectSlug: string, modelSlug: string, patchModel: PatchModel, @@ -145,6 +145,23 @@ export class ModelService { ); }; } + + moveModelToProject( + projectSlug: string, + modelSlug: string, + project_slug: string, + ): Observable { + return this.http + .patch(`${this.backendURLFactory(projectSlug, modelSlug)}/move`, { + project_slug, + }) + .pipe( + tap(() => { + this.loadModels(projectSlug); + this._model.next(undefined); + }), + ); + } } export type NewModel = { @@ -171,6 +188,7 @@ export type PatchModel = { description?: string; nature_id?: number; version_id?: number; + project_slug?: string; }; export function getPrimaryGitModel(model: Model): GetGitModel | undefined { diff --git a/frontend/src/app/projects/project-detail/model-overview/model-overview.component.html b/frontend/src/app/projects/project-detail/model-overview/model-overview.component.html index 50438f229f..3998bf63fb 100644 --- a/frontend/src/app/projects/project-detail/model-overview/model-overview.component.html +++ b/frontend/src/app/projects/project-detail/model-overview/model-overview.component.html @@ -105,6 +105,15 @@

Models

> key + + +
+

Move Model to Different Project

+
Select the project to which you want to move the model to.
+ + Search + + search + +
+
+
+ {{ project.name }} + +
+
+
+
+
+ You cannot move this model to another project. If this is an error please + contact your administrator. +
+
diff --git a/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.ts b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.ts new file mode 100644 index 0000000000..72e472e6a2 --- /dev/null +++ b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogRef, +} from '@angular/material/dialog'; +import { Observable, map } from 'rxjs'; +import { ConfirmationDialogComponent } from 'src/app/general/confirmation-dialog/confirmation-dialog.component'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { + Model, + ModelService, +} from 'src/app/projects/models/service/model.service'; +import { + Project, + ProjectService, +} from 'src/app/projects/service/project.service'; + +@Component({ + selector: 'app-move-model', + templateUrl: './move-model.component.html', + styleUrls: ['./move-model.component.css'], +}) +export class MoveModelComponent { + selectedProject?: Project; + search = ''; + filteredProjects$: Observable; + constructor( + private modelService: ModelService, + private dialogRef: MatDialogRef, + private toastService: ToastService, + public projectService: ProjectService, + private dialog: MatDialog, + @Inject(MAT_DIALOG_DATA) + public data: { projectSlug: string; model: Model }, + ) { + this.projectService.loadProjectsForRole('manager'); + this.filteredProjects$ = projectService.projects$.pipe( + map( + (projects) => + projects?.filter((project) => project.slug !== data.projectSlug), + ), + ); + } + + onProjectSelect(project: Project) { + this.selectedProject = project; + } + + async moveModelToProject(project: Project) { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Move Model', + text: `Do you really want to move the model ${this.data.model.slug} to the project ${project.slug}?`, + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.modelService + .updateModel(this.data.projectSlug, this.data.model.slug, { + description: '', + nature_id: undefined, + version_id: undefined, + project_slug: project.slug, + }) + .subscribe(() => { + this.toastService.showSuccess( + 'Model moved', + `The model “${this.data.model.name}” was successfuly moved to project "${project.slug}".`, + ); + this.dialogRef.close(); + }); + } + }); + } + + searchAndFilteredProjects(): Observable { + if (!this.search) { + return this.filteredProjects$; + } + return this.filteredProjects$.pipe( + map( + (projects) => + projects?.filter((project) => + project.slug.toLowerCase().includes(this.search.toLowerCase()), + ), + ), + ); + } +} diff --git a/frontend/src/app/projects/service/project.service.ts b/frontend/src/app/projects/service/project.service.ts index 270b646dad..f642f298d4 100644 --- a/frontend/src/app/projects/service/project.service.ts +++ b/frontend/src/app/projects/service/project.service.ts @@ -35,6 +35,15 @@ export class ProjectService { }); } + loadProjectsForRole(role: string): void { + this.http + .get(`${this.BACKEND_URL_PREFIX}/?minimum_role=${role}`) + .subscribe({ + next: (projects) => this._projects.next(projects), + error: () => this._projects.next(undefined), + }); + } + loadProjectBySlug(slug: string): void { this.http.get(`${this.BACKEND_URL_PREFIX}/${slug}`).subscribe({ next: (project) => this._project.next(project),