diff --git a/backend/capellacollab/projects/toolmodels/crud.py b/backend/capellacollab/projects/toolmodels/crud.py index 8032f945a..b8539ae70 100644 --- a/backend/capellacollab/projects/toolmodels/crud.py +++ b/backend/capellacollab/projects/toolmodels/crud.py @@ -129,6 +129,7 @@ def update_model( db: orm.Session, model: models.DatabaseCapellaModel, description: str | None, + name: str | None, version: tools_models.DatabaseVersion, nature: tools_models.DatabaseNature, ) -> models.DatabaseCapellaModel: @@ -136,6 +137,9 @@ def update_model( model.nature = nature if description: model.description = description + if name: + model.name = name + model.slug = slugify.slugify(name) db.commit() return model diff --git a/backend/capellacollab/projects/toolmodels/models.py b/backend/capellacollab/projects/toolmodels/models.py index 0ad5badbe..c01439d2e 100644 --- a/backend/capellacollab/projects/toolmodels/models.py +++ b/backend/capellacollab/projects/toolmodels/models.py @@ -49,6 +49,7 @@ class PostCapellaModel(pydantic.BaseModel): class PatchCapellaModel(pydantic.BaseModel): + name: str | None = None description: str | None = None version_id: int nature_id: int diff --git a/backend/capellacollab/projects/toolmodels/routes.py b/backend/capellacollab/projects/toolmodels/routes.py index 069b707ba..dccc25c12 100644 --- a/backend/capellacollab/projects/toolmodels/routes.py +++ b/backend/capellacollab/projects/toolmodels/routes.py @@ -4,6 +4,7 @@ import uuid import fastapi +import slugify from fastapi import status from sqlalchemy import exc, orm @@ -121,11 +122,27 @@ def create_new_tool_model( ) def patch_tool_model( body: models.PatchCapellaModel, + project: projects_models.DatabaseProject = fastapi.Depends( + projects_injectables.get_existing_project + ), model: models.DatabaseCapellaModel = fastapi.Depends( injectables.get_existing_capella_model ), db: orm.Session = fastapi.Depends(database.get_db), ) -> models.DatabaseCapellaModel: + if body.name: + new_slug = slugify.slugify(body.name) + + if model.slug != new_slug and crud.get_model_by_slugs( + db, project.slug, new_slug + ): + raise fastapi.HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "reason": "A model with a similar name already exists." + }, + ) + version = get_version_by_id_or_raise(db, body.version_id) if version.tool != model.tool: raise fastapi.HTTPException( @@ -144,7 +161,9 @@ def patch_tool_model( }, ) - return crud.update_model(db, model, body.description, version, nature) + return crud.update_model( + db, model, body.description, body.name, version, nature + ) @router.delete( diff --git a/backend/tests/projects/toolmodels/test_toolmodel_routes.py b/backend/tests/projects/toolmodels/test_toolmodel_routes.py new file mode 100644 index 000000000..b5e9fc6a5 --- /dev/null +++ b/backend/tests/projects/toolmodels/test_toolmodel_routes.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + + +from unittest import mock + +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.__main__ import app +from capellacollab.projects import injectables as projects_injectables +from capellacollab.projects import models as projects_models +from capellacollab.projects.toolmodels import ( + injectables as toolmodels_injectables, +) +from capellacollab.projects.toolmodels import models as toolmodels_models +from capellacollab.users import crud as users_crud +from capellacollab.users import models as users_models + + +@pytest.fixture(name="override_dependency") +def fixture_override_dependency(): + mock_project = mock.Mock(name="DatabaseProject") + + mock_model = mock.Mock(name="DatabaseModel") + mock_model.slug = "any-slug" + mock_model.tool = mock.Mock(name="tool") + + app.dependency_overrides[ + projects_injectables.get_existing_project + ] = lambda: mock_project + app.dependency_overrides[ + toolmodels_injectables.get_existing_capella_model + ] = lambda: mock_model + + yield + + del app.dependency_overrides[projects_injectables.get_existing_project] + del app.dependency_overrides[ + toolmodels_injectables.get_existing_capella_model + ] + + +def test_rename_toolmodel_successful( + capella_model: toolmodels_models.DatabaseCapellaModel, + project: projects_models.DatabaseProject, + client: testclient.TestClient, + executor_name: str, + db: orm.Session, +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + response = client.patch( + f"/api/v1/projects/{project.slug}/models/{capella_model.slug}", + json={ + "name": "new-name", + "version_id": capella_model.tool.versions[0].id, + "nature_id": capella_model.tool.natures[0].id, + }, + ) + + assert response.status_code == 200 + assert "new-name" in response.text + + +@pytest.mark.usefixtures("override_dependency") +def test_rename_toolmodel_where_name_already_exists( + client: testclient.TestClient, + executor_name: str, + db: orm.Session, +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + with mock.patch( + "capellacollab.projects.toolmodels.crud.get_model_by_slugs", + autospec=True, + ) as mock_get_model_by_slugs: + mock_get_model_by_slugs.return_value = "anything" + + response = client.patch( + "/api/v1/projects/any/models/any", + json={"name": "new-name", "version_id": -1, "nature_id": -1}, + ) + + assert response.status_code == 409 + assert "A model with a similar name already exists" in response.text + mock_get_model_by_slugs.assert_called_once() diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 320654831..8f34c0896 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -5,6 +5,7 @@ import { AfterViewInit, Component, ViewChild } from '@angular/core'; import { MatSidenav } from '@angular/material/sidenav'; +import slugify from 'slugify'; import { NavBarService } from 'src/app/general/nav-bar/nav-bar.service'; import { PageLayoutService } from './page-layout/page-layout.service'; import { FullscreenService } from './sessions/service/fullscreen.service'; @@ -19,7 +20,9 @@ export class AppComponent implements AfterViewInit { public pageLayoutService: PageLayoutService, public fullscreenService: FullscreenService, private navBarService: NavBarService, - ) {} + ) { + slugify.extend({ '.': '-' }); + } @ViewChild('sidenav') private sidenav?: MatSidenav; diff --git a/frontend/src/app/projects/models/model-description/model-description.component.html b/frontend/src/app/projects/models/model-description/model-description.component.html index 96fcd7cf5..f707f65d7 100644 --- a/frontend/src/app/projects/models/model-description/model-description.component.html +++ b/frontend/src/app/projects/models/model-description/model-description.component.html @@ -7,6 +7,17 @@ {{ (modelService.model$ | async)!.name }}
+
+ + Name + + + A model with a similar name already exists. + +
+
Description @@ -62,7 +73,14 @@ Delete - + 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 1a70fc903..c5fc38528 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 @@ -25,6 +25,7 @@ import { ProjectService } from '../../service/project.service'; }) export class ModelDescriptionComponent implements OnInit { form = new FormGroup({ + name: new FormControl(''), description: new FormControl(''), nature: new FormControl(-1), version: new FormControl(-1), @@ -57,7 +58,12 @@ export class ModelDescriptionComponent implements OnInit { model.git_models.length || model.t4c_models.length ); + this.form.controls.name.setAsyncValidators( + this.modelService.asyncSlugValidator(model), + ); + this.form.patchValue({ + name: model.name, description: model.description, nature: model.nature?.id, version: model.version?.id, @@ -83,14 +89,21 @@ export class ModelDescriptionComponent implements OnInit { onSubmit(): void { if (this.form.value && this.modelSlug && this.projectSlug) { this.modelService - .updateModelDescription(this.projectSlug!, this.modelSlug!, { + .updateModelDescription(this.projectSlug, this.modelSlug, { + name: this.form.value.name || undefined, description: this.form.value.description || '', nature_id: this.form.value.nature || undefined, version_id: this.form.value.version || undefined, }) - .subscribe(() => - this.router.navigate(['../../..'], { relativeTo: this.route }), - ); + .subscribe({ + next: () => { + this.toastService.showSuccess( + 'Model updated', + `${this.modelSlug} has been updated`, + ); + this.router.navigate(['../../..'], { relativeTo: this.route }); + }, + }); } } diff --git a/frontend/src/app/projects/models/service/model.service.ts b/frontend/src/app/projects/models/service/model.service.ts index 1ebf2a121..00402bcca 100644 --- a/frontend/src/app/projects/models/service/model.service.ts +++ b/frontend/src/app/projects/models/service/model.service.ts @@ -108,7 +108,6 @@ export class ModelService { this.loadModels(projectSlug); this._model.next(model); }, - error: () => this._model.next(undefined), }), ); } @@ -132,13 +131,16 @@ export class ModelService { this._models.next(undefined); } - asyncSlugValidator(): AsyncValidatorFn { + asyncSlugValidator(ignoreModel?: Model): AsyncValidatorFn { + const ignoreSlug = !!ignoreModel ? ignoreModel.slug : -1; return (control: AbstractControl): Observable => { const modelSlug = slugify(control.value, { lower: true }); return this.models$.pipe( take(1), map((models) => { - return models?.find((model) => model.slug === modelSlug) + return models?.find( + (model) => model.slug === modelSlug && model.slug !== ignoreSlug, + ) ? { uniqueSlug: { value: modelSlug } } : null; }), @@ -168,6 +170,7 @@ export type Model = { }; export type PatchModel = { + name?: string; description?: string; nature_id?: number; version_id?: number;