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 }}
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;