diff --git a/backend/capellacollab/projects/routes.py b/backend/capellacollab/projects/routes.py index 49a01f3837..428cfb31c8 100644 --- a/backend/capellacollab/projects/routes.py +++ b/backend/capellacollab/projects/routes.py @@ -40,6 +40,7 @@ @router.get("", response_model=list[models.Project], tags=["Projects"]) def get_projects( + minimum_role: str | None = None, user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user ), @@ -54,13 +55,37 @@ def get_projects( )(token, db): 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: + roles = [ + projects_users_models.ProjectUserRole.USER, + projects_users_models.ProjectUserRole.MANAGER, + projects_users_models.ProjectUserRole.ADMIN, + ] + match minimum_role.lower(): + case "user": + required_role = roles + case "manager": + required_role = roles[1:] + case "administrator": + required_role = roles[2:] + case _: + raise fastapi.HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"reason": "Requested minimum role does not exist"}, + ) + projects = [ + association.project + for association in user.projects + if association.role in required_role + ] 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..1ea1ba3c77 100644 --- a/backend/capellacollab/projects/toolmodels/crud.py +++ b/backend/capellacollab/projects/toolmodels/crud.py @@ -140,6 +140,17 @@ def update_model( return model +def move_model( + db: orm.Session, + model: models.DatabaseCapellaModel, + project: projects_model.DatabaseProject, +) -> models.DatabaseCapellaModel: + model.project = project + model.project_id = project.id + db.commit() + return model + + def delete_model(db: orm.Session, model: models.DatabaseCapellaModel): db.delete(model) db.commit() diff --git a/backend/capellacollab/projects/toolmodels/routes.py b/backend/capellacollab/projects/toolmodels/routes.py index 069b707ba8..0695e7222c 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 @@ -147,6 +149,53 @@ def patch_tool_model( return crud.update_model(db, model, body.description, version, nature) +@router.patch( + "/{model_slug}/move", + response_model=models.CapellaModel, + dependencies=[ + fastapi.Depends( + auth_injectables.ProjectRoleVerification( + required_role=projects_users_models.ProjectUserRole.MANAGER + ) + ) + ], + tags=["Projects - Models"], +) +def move_tool_model( + body: dict, + model: models.DatabaseCapellaModel = fastapi.Depends( + 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: + new_project = projects_injectables.get_existing_project( + body["new_project_slug"], db + ) + success = False + 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 crud.move_model(db, model, new_project) + + @router.delete( "/{model_slug}", status_code=204, diff --git a/backend/tests/projects/test_projects_routes.py b/backend/tests/projects/test_projects_routes.py index 165128fe8d..ffd2fd38c1 100644 --- a/backend/tests/projects/test_projects_routes.py +++ b/backend/tests/projects/test_projects_routes.py @@ -209,3 +209,34 @@ def test_update_project_as_admin( "visibility": "internal", "users": {"leads": 0, "contributors": 0, "subscribers": 0}, } == response.json() + + +@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..7259fdda03 --- /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}/move", + json={"new_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}/move", + json={"new_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 58fc320330..2015a268b4 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -91,6 +91,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 { AddUserToProjectComponent } from './projects/project-detail/project-users/add-user-to-project/add-user-to-project.component'; @@ -186,6 +187,7 @@ import { SettingsComponent } from './settings/settings.component'; ModelOverviewComponent, ModelRestrictionsComponent, ModelWrapperComponent, + MoveModelComponent, NavBarMenuComponent, NoticeComponent, PipelineRunWrapperComponent, diff --git a/frontend/src/app/projects/models/service/model.service.ts b/frontend/src/app/projects/models/service/model.service.ts index 318c8d859e..0ef2333a6e 100644 --- a/frontend/src/app/projects/models/service/model.service.ts +++ b/frontend/src/app/projects/models/service/model.service.ts @@ -145,6 +145,23 @@ export class ModelService { ); }; } + + moveModelToProject( + projectSlug: string, + modelSlug: string, + new_project_slug: string + ): Observable { + return this.http + .patch(`${this.backendURLFactory(projectSlug, modelSlug)}/move`, { + new_project_slug, + }) + .pipe( + tap(() => { + this.loadModels(projectSlug); + this._model.next(undefined); + }) + ); + } } export type NewModel = { 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 db1e764bad..2394b61c8a 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 @@ -93,6 +93,14 @@

Models

> key + { + this.dialog.open(MoveModelComponent, { + height: '40vh', + width: '40vw', + data: { project_slug: project?.slug, model: model }, + }); + }); + } } diff --git a/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.css b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.css new file mode 100644 index 0000000000..d49deaffd7 --- /dev/null +++ b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.css @@ -0,0 +1,4 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ diff --git a/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.html b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.html new file mode 100644 index 0000000000..62e089646f --- /dev/null +++ b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.html @@ -0,0 +1,48 @@ + + + +

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..227c595314 --- /dev/null +++ b/frontend/src/app/projects/project-detail/model-overview/move-model/move-model.component.ts @@ -0,0 +1,76 @@ +/* + * 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, MatDialogRef } from '@angular/material/dialog'; +import { Observable, map } from 'rxjs'; +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, + @Inject(MAT_DIALOG_DATA) + public data: { project_slug: string; model: Model } + ) { + this.projectService.loadProjectsForRole('manager'); + this.filteredProjects$ = projectService.projects$.pipe( + map((projects) => + projects?.filter((project) => project.slug !== data.project_slug) + ) + ); + } + + onProjectSelect(project: Project) { + this.selectedProject = project; + } + + async moveModelToProject(project: Project) { + this.modelService + .moveModelToProject( + this.data.project_slug, + this.data.model.slug, + project.slug + ) + .subscribe(() => { + this.toastService.showSuccess( + 'Model moved', + `The model “${this.data.model.name}” was successfuly moved to project "${this.data.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 0c2a41421f..ecda5c3232 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),