Skip to content

Commit

Permalink
feat: Move model from one project to another
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Paula-Kli committed Oct 10, 2023
1 parent 1e68933 commit 9c92dc2
Show file tree
Hide file tree
Showing 13 changed files with 367 additions and 0 deletions.
46 changes: 46 additions & 0 deletions backend/capellacollab/projects/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


import logging
from collections import abc

import fastapi
import slugify
Expand Down Expand Up @@ -66,6 +67,51 @@ def get_projects(
return projects


@router.get(
"/role/{role}",
response_model=abc.Sequence[models.Project],
tags=["Projects"],
)
def get_projects_by_role(
role: str,
user: users_models.DatabaseUser = fastapi.Depends(
users_injectables.get_own_user
),
db: orm.Session = fastapi.Depends(database.get_db),
token=fastapi.Depends(jwt_bearer.JWTBearer()),
log: logging.LoggerAdapter = fastapi.Depends(
core_logging.get_request_logger
),
) -> abc.Sequence[models.DatabaseProject]:
if auth_injectables.RoleVerification(
required_role=users_models.Role.ADMIN, verify=False
)(token, db):
log.debug("Fetching all projects")
return crud.get_projects(db)

match role.lower():
case "user":
required_role = projects_users_models.ProjectUserRole.USER
case "manager":
required_role = projects_users_models.ProjectUserRole.MANAGER
case "administrator":
required_role = projects_users_models.ProjectUserRole.ADMIN
case _:
raise fastapi.HTTPException(

Check warning on line 100 in backend/capellacollab/projects/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/projects/routes.py#L98-L100

Added lines #L98 - L100 were not covered by tests
status_code=status.HTTP_400_BAD_REQUEST,
detail={"reason": "Requested role does not exist"},
)

projects = [
association.project
for association in user.projects
if association.role == required_role
]

log.debug("Fetching the following projects: %s", projects)
return projects


@router.patch(
"/{project_slug}",
response_model=models.Project,
Expand Down
11 changes: 11 additions & 0 deletions backend/capellacollab/projects/toolmodels/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
49 changes: 49 additions & 0 deletions backend/capellacollab/projects/toolmodels/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Check warning on line 184 in backend/capellacollab/projects/toolmodels/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/projects/toolmodels/routes.py#L184

Added line #L184 was not covered by tests
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,
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/projects/test_projects_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,30 @@ 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/role/user")
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/role/manager")
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/role/manager")
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/role/administrator")
assert len(response.json()) > 0
59 changes: 59 additions & 0 deletions backend/tests/projects/toolmodels/test_toolmodels.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -186,6 +187,7 @@ import { SettingsComponent } from './settings/settings.component';
ModelOverviewComponent,
ModelRestrictionsComponent,
ModelWrapperComponent,
MoveModelComponent,
NavBarMenuComponent,
NoticeComponent,
PipelineRunWrapperComponent,
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/projects/models/service/model.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@ export class ModelService {
);
};
}

moveModelToProject(
projectSlug: string,
modelSlug: string,
new_project_slug: string
): Observable<Model> {
return this.http
.patch<Model>(`${this.backendURLFactory(projectSlug, modelSlug)}/move`, {
new_project_slug,
})
.pipe(
tap(() => {
this.loadModels(projectSlug);
this._model.next(undefined);
})
);
}
}

export type NewModel = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ <h2>Models</h2>
>
<mat-icon>key</mat-icon>
</a>
<button
mat-mini-fab
matTooltip="Move model to different project"
(click)="openMoveToProjectDialog(model)"
*ngIf="projectUserService.verifyRole('manager')"
>
<mat-icon>drive_file_move</mat-icon>
</button>
<a
mat-mini-fab
matTooltip="Configure model sources"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Model,
ModelService,
} from 'src/app/projects/models/service/model.service';
import { MoveModelComponent } from 'src/app/projects/project-detail/model-overview/move-model/move-model.component';
import { ProjectUserService } from 'src/app/projects/project-detail/project-users/service/project-user.service';
import { UserService } from 'src/app/services/user/user.service';
import { SessionService } from 'src/app/sessions/service/session.service';
Expand Down Expand Up @@ -79,4 +80,14 @@ export class ModelOverviewComponent implements OnInit {
const primaryModel = getPrimaryGitModel(model);
return primaryModel ? primaryModel.path : '';
}

openMoveToProjectDialog(model: Model): void {
this.projectService.project$.pipe(first()).subscribe((project) => {
this.dialog.open(MoveModelComponent, {
height: '40vh',
width: '40vw',
data: { project_slug: project?.slug, model: model },
});
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*
* SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors
* SPDX-License-Identifier: Apache-2.0
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!--
~ SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors
~ SPDX-License-Identifier: Apache-2.0
-->

<mat-card>
<h2>Move Model to Different Project</h2>
<div>Select the project to which you want to move the model to.</div>
<mat-form-field id="search" appearance="outline" class="w-full">
<mat-label>Search</mat-label>
<input
[(ngModel)]="search"
autocomplete="off"
matInput
placeholder="Project Name"
/>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<mat-selection-list
[multiple]="false"
class="scrollable simple-scroll overflow-y-auto max-h-72"
(selectionChange)="onProjectSelect($event.options[0].value)"
*ngIf="(filteredProjects$ | async)?.length; else elseBlock"
>
<mat-list-option
*ngFor="let project of searchAndFilteredProjects() | async"
[value]="project"
>
<div mat-line>{{ project.name }}</div>
</mat-list-option>
</mat-selection-list>
<div *ngIf="selectedProject">
<button
mat-flat-button
color="primary"
(click)="moveModelToProject(selectedProject)"
>
<mat-icon>drive_file_move</mat-icon>
Move model {{ data.model.name }} to project {{ selectedProject.name }}
</button>
</div>
<ng-template #elseBlock
><div>
You cannot move this model to another project. If this is an error please
contact your administrator.
</div></ng-template
>
</mat-card>
Loading

0 comments on commit 9c92dc2

Please sign in to comment.