diff --git a/backend/capellacollab/__main__.py b/backend/capellacollab/__main__.py index 4f310aafa..99fd90e87 100644 --- a/backend/capellacollab/__main__.py +++ b/backend/capellacollab/__main__.py @@ -39,6 +39,9 @@ from capellacollab.routes import router from capellacollab.sessions import exceptions as sessions_exceptions from capellacollab.sessions import idletimeout, operators +from capellacollab.settings.modelsources.t4c import ( + exceptions as settings_t4c_exceptions, +) from capellacollab.tools import exceptions as tools_exceptions from capellacollab.users import exceptions as users_exceptions @@ -145,6 +148,7 @@ def register_exceptions(): core_exceptions.register_exceptions(app) users_exceptions.register_exceptions(app) sessions_exceptions.register_exceptions(app) + settings_t4c_exceptions.register_exceptions(app) register_exceptions() diff --git a/backend/capellacollab/alembic/versions/f7bf9456cfc9_add_archive_flag.py b/backend/capellacollab/alembic/versions/f7bf9456cfc9_add_archive_flag.py new file mode 100644 index 000000000..0cac1e9f1 --- /dev/null +++ b/backend/capellacollab/alembic/versions/f7bf9456cfc9_add_archive_flag.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add archive flag + +Revision ID: f7bf9456cfc9 +Revises: 1a4208c18909 +Create Date: 2023-08-28 08:57:22.931913 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f7bf9456cfc9" +down_revision = "1a4208c18909" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "t4c_instances", sa.Column("is_archived", sa.Boolean(), nullable=True) + ) + + op.get_bind().execute( + sa.text("UPDATE t4c_instances SET is_archived=false") + ) + + op.alter_column( + "t4c_instances", + "is_archived", + existing_type=sa.BOOLEAN(), + nullable=False, + ) diff --git a/backend/capellacollab/projects/toolmodels/modelsources/t4c/routes.py b/backend/capellacollab/projects/toolmodels/modelsources/t4c/routes.py index 3bb4969e6..916bbd0bb 100644 --- a/backend/capellacollab/projects/toolmodels/modelsources/t4c/routes.py +++ b/backend/capellacollab/projects/toolmodels/modelsources/t4c/routes.py @@ -79,7 +79,7 @@ def create_t4c_model( ), db: orm.Session = fastapi.Depends(database.get_db), ): - instance = settings_t4c_injecatbles.get_existing_instance( + instance = settings_t4c_injecatbles.get_existing_unarchived_instance( body.t4c_instance_id, db ) repository = ( diff --git a/backend/capellacollab/settings/modelsources/t4c/exceptions.py b/backend/capellacollab/settings/modelsources/t4c/exceptions.py new file mode 100644 index 000000000..f8826ecea --- /dev/null +++ b/backend/capellacollab/settings/modelsources/t4c/exceptions.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi +from fastapi import exception_handlers, status + + +class T4CInstanceIsArchivedError(Exception): + def __init__(self, t4c_instance_id: int): + self.t4c_instance_id = t4c_instance_id + + +async def t4c_instance_is_archived_exception_handler( + request: fastapi.Request, exc: T4CInstanceIsArchivedError +) -> fastapi.Response: + return await exception_handlers.http_exception_handler( + request, + fastapi.HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "reason": f"The T4C instance identified by {exc.t4c_instance_id} is archived, thus prohibiting the execution of the requested operation." + }, + ), + ) + + +def register_exceptions(app: fastapi.FastAPI): + app.add_exception_handler( + T4CInstanceIsArchivedError, t4c_instance_is_archived_exception_handler + ) diff --git a/backend/capellacollab/settings/modelsources/t4c/injectables.py b/backend/capellacollab/settings/modelsources/t4c/injectables.py index 3334eb0a1..3c62f180e 100644 --- a/backend/capellacollab/settings/modelsources/t4c/injectables.py +++ b/backend/capellacollab/settings/modelsources/t4c/injectables.py @@ -7,7 +7,7 @@ from capellacollab.core import database -from . import crud, models +from . import crud, exceptions, models def get_existing_instance( @@ -22,3 +22,13 @@ def get_existing_instance( "reason": f"The t4c instance with the id {t4c_instance_id} does not exist.", }, ) + + +def get_existing_unarchived_instance( + t4c_instance_id: int, db: orm.Session = fastapi.Depends(database.get_db) +): + t4c_instance = get_existing_instance(t4c_instance_id, db) + if t4c_instance.is_archived: + raise exceptions.T4CInstanceIsArchivedError(t4c_instance.id) + + return t4c_instance diff --git a/backend/capellacollab/settings/modelsources/t4c/models.py b/backend/capellacollab/settings/modelsources/t4c/models.py index d89ae1458..fc9ff63f1 100644 --- a/backend/capellacollab/settings/modelsources/t4c/models.py +++ b/backend/capellacollab/settings/modelsources/t4c/models.py @@ -79,6 +79,8 @@ class DatabaseT4CInstance(database.Base): back_populates="instance", cascade="all, delete" ) + is_archived: orm.Mapped[bool] = orm.mapped_column(default=False) + def port_validator(value: int | None) -> int | None: if not value: @@ -123,6 +125,7 @@ class PatchT4CInstance(pydantic.BaseModel): password: str | None = None protocol: Protocol | None = None version_id: int | None = None + is_archived: bool | None = None _validate_rest_api_url = pydantic.field_validator("rest_api")( validate_rest_api_url @@ -138,9 +141,11 @@ class T4CInstanceComplete(T4CInstanceBase): class CreateT4CInstance(T4CInstanceComplete): + is_archived: bool | None = None password: str class T4CInstance(T4CInstanceComplete): id: int version: tools_models.ToolVersionBase + is_archived: bool diff --git a/backend/capellacollab/settings/modelsources/t4c/routes.py b/backend/capellacollab/settings/modelsources/t4c/routes.py index 7af12e196..4bee579bc 100644 --- a/backend/capellacollab/settings/modelsources/t4c/routes.py +++ b/backend/capellacollab/settings/modelsources/t4c/routes.py @@ -15,7 +15,7 @@ ) from capellacollab.users import models as users_models -from . import crud, injectables, interface, models +from . import crud, exceptions, injectables, interface, models router = fastapi.APIRouter( dependencies=[ @@ -72,6 +72,9 @@ def edit_t4c_instance( ), db: orm.Session = fastapi.Depends(database.get_db), ) -> models.DatabaseT4CInstance: + if instance.is_archived and (body.is_archived is None or body.is_archived): + raise exceptions.T4CInstanceIsArchivedError(instance.id) + return crud.update_t4c_instance(db, instance, body) diff --git a/backend/tests/settings/conftest.py b/backend/tests/settings/conftest.py index 8451db03c..b5cd280be 100644 --- a/backend/tests/settings/conftest.py +++ b/backend/tests/settings/conftest.py @@ -25,8 +25,8 @@ def fixture_admin_user( return users_crud.create_user(db, executor_name, users_models.Role.ADMIN) -@pytest.fixture(name="t4c_server") -def fixture_t4c_server( +@pytest.fixture(name="t4c_instance") +def fixture_t4c_instance( db: orm.Session, test_tool_version: tools_models.DatabaseVersion, ) -> t4c_models.DatabaseT4CInstance: diff --git a/backend/tests/settings/test_t4c_instances.py b/backend/tests/settings/test_t4c_instances.py index 6002459af..5ec234dc2 100644 --- a/backend/tests/settings/test_t4c_instances.py +++ b/backend/tests/settings/test_t4c_instances.py @@ -7,6 +7,12 @@ from sqlalchemy import orm from capellacollab.settings.modelsources.t4c import crud as t4c_crud +from capellacollab.settings.modelsources.t4c import ( + exceptions as settings_t4c_exceptions, +) +from capellacollab.settings.modelsources.t4c import ( + injectables as settings_t4c_injectables, +) from capellacollab.settings.modelsources.t4c import models as t4c_models from capellacollab.tools import models as tools_models from capellacollab.users import crud as users_crud @@ -45,7 +51,7 @@ def test_create_t4c_instance( assert t4c_instance.name == "Test integration" -@pytest.mark.usefixtures("t4c_server") +@pytest.mark.usefixtures("t4c_instance") def test_get_t4c_instances( client: testclient.TestClient, db: orm.Session, executor_name: str ): @@ -67,12 +73,12 @@ def test_get_t4c_instance( client: testclient.TestClient, db: orm.Session, executor_name: str, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): users_crud.create_user(db, executor_name, users_models.Role.ADMIN) response = client.get( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}", ) assert response.json()["name"] == "test server" @@ -85,50 +91,125 @@ def test_patch_t4c_instance( client: testclient.TestClient, db: orm.Session, executor_name: str, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): users_crud.create_user(db, executor_name, users_models.Role.ADMIN) response = client.patch( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}", json={ "name": "Patched test integration", }, ) - t4c_instance = t4c_crud.get_t4c_instance_by_id(db, response.json()["id"]) - assert t4c_instance + updated_t4c_instance = t4c_crud.get_t4c_instance_by_id( + db, response.json()["id"] + ) + assert updated_t4c_instance + + assert response.status_code == 200 assert response.json()["name"] == "Patched test integration" - assert t4c_instance.name == "Patched test integration" + assert updated_t4c_instance.name == "Patched test integration" assert response.json()["host"] == "localhost" - assert t4c_instance.host == "localhost" + assert updated_t4c_instance.host == "localhost" + + +def test_patch_archived_t4c_instance_error( + client: testclient.TestClient, + db: orm.Session, + executor_name: str, + t4c_instance: t4c_models.DatabaseT4CInstance, +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + t4c_crud.update_t4c_instance( + db, t4c_instance, t4c_models.PatchT4CInstance(is_archived=True) + ) + + response = client.patch( + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}", + json={ + "name": "Patched test integration", + }, + ) + + assert response.status_code == 400 + + +def test_unarchive_t4c_instance( + client: testclient.TestClient, + db: orm.Session, + executor_name: str, + t4c_instance: t4c_models.DatabaseT4CInstance, +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + t4c_crud.update_t4c_instance( + db, t4c_instance, t4c_models.PatchT4CInstance(is_archived=True) + ) + + assert t4c_instance.is_archived + + response = client.patch( + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}", + json={ + "is_archived": False, + }, + ) + + assert response.status_code == 200 + + updated_t4c_instance = t4c_crud.get_t4c_instance_by_id( + db, response.json()["id"] + ) + assert updated_t4c_instance + + assert not response.json()["is_archived"] + assert not updated_t4c_instance.is_archived + + +def test_injectables_raise_when_archived_instance( + db: orm.Session, + executor_name: str, + t4c_instance: t4c_models.DatabaseT4CInstance, +): + users_crud.create_user(db, executor_name, users_models.Role.ADMIN) + + t4c_crud.update_t4c_instance( + db, t4c_instance, t4c_models.PatchT4CInstance(is_archived=True) + ) + + with pytest.raises(settings_t4c_exceptions.T4CInstanceIsArchivedError): + settings_t4c_injectables.get_existing_unarchived_instance( + t4c_instance.id, db + ) def test_update_t4c_instance_password_empty_string( client: testclient.TestClient, db: orm.Session, executor_name: str, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): users_crud.create_user(db, executor_name, users_models.Role.ADMIN) - expected_password = t4c_server.password + expected_password = t4c_instance.password response = client.patch( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}", json={ "password": "", }, ) - updated_t4c_server = t4c_crud.get_t4c_instance_by_id( + updated_t4c_instance = t4c_crud.get_t4c_instance_by_id( db, response.json()["id"] ) - assert updated_t4c_server - assert updated_t4c_server.password == expected_password + assert updated_t4c_instance + assert updated_t4c_instance.password == expected_password @responses.activate @@ -136,7 +217,7 @@ def test_get_t4c_license_usage( client: testclient.TestClient, db: orm.Session, executor_name: str, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): users_crud.create_user(db, executor_name, users_models.Role.ADMIN) responses.get( @@ -146,7 +227,7 @@ def test_get_t4c_license_usage( ) response = client.get( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}/licenses", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}/licenses", ) assert response.status_code == 200 @@ -159,7 +240,7 @@ def test_get_t4c_license_usage_no_status( client: testclient.TestClient, db: orm.Session, executor_name: str, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): users_crud.create_user(db, executor_name, users_models.Role.ADMIN) responses.get( @@ -169,7 +250,7 @@ def test_get_t4c_license_usage_no_status( ) response = client.get( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}/licenses", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}/licenses", ) assert response.status_code == 404 diff --git a/backend/tests/settings/test_t4c_repositories.py b/backend/tests/settings/test_t4c_repositories.py index 985064d29..8be34b9ba 100644 --- a/backend/tests/settings/test_t4c_repositories.py +++ b/backend/tests/settings/test_t4c_repositories.py @@ -23,7 +23,7 @@ def test_list_t4c_repositories( client: testclient.TestClient, db: orm.Session, - t4c_server: t4c_models.DatabaseT4CInstance, + t4c_instance: t4c_models.DatabaseT4CInstance, ): responses.get( "http://localhost:8080/api/v1.0/repositories", @@ -41,10 +41,10 @@ def test_list_t4c_repositories( }, ) - t4c_repositories_crud.create_t4c_repository(db, "test4", t4c_server) - t4c_repositories_crud.create_t4c_repository(db, "test5", t4c_server) + t4c_repositories_crud.create_t4c_repository(db, "test4", t4c_instance) + t4c_repositories_crud.create_t4c_repository(db, "test5", t4c_instance) response = client.get( - f"/api/v1/settings/modelsources/t4c/{t4c_server.id}/repositories", + f"/api/v1/settings/modelsources/t4c/{t4c_instance.id}/repositories", ) assert response.status_code == 200 diff --git a/docs/user/docs/settings/model-sources/t4c.md b/docs/user/docs/settings/model-sources/t4c.md new file mode 100644 index 000000000..c75d462f2 --- /dev/null +++ b/docs/user/docs/settings/model-sources/t4c.md @@ -0,0 +1,40 @@ + + +# Manage T4C instances + +## Define a T4C instance + +1. Please navigate to `Profile` > `Settings` +1. Select `T4C` bewlow `Model sources` +1. You can see all existing instances (if any). To add a new instance, click + on the "Add an instance" card. You have to enter the following information: + + 1. **Name**: Any name to identify the instance + 1. **Capella version**: Capella version that corresponds to the instance + 1. **License configuration**: License key of your license server + 1. **Protocol**: Protocol that should be used to communicate between + capella sessions and the T4C server + 1. **Host**: Hostname of the T4C server + 1. **Port**, **CDO Port**, and **HTTP Port** Corresponding ports of your server + 1. **License server API**: License server API url + 1. **REST API**: REST API URL of the T4C server + 1. **Username**: Username with access to the REST API, required for communication + with the REST API + 1. **Password**: Password corresponding to username + +## Archive a T4C instance + +1. Please navigate to `Profile` > `Settings` +1. Select `T4C` bewlow `Model sources` +1. Click on the instance that you want to archive +1. Click on the `Archive` button. When everything worked you should see a + messages stating "Instance updated: The instance _name_ is now archived" + +An archived instance can no longer be selected when creating a new T4C model +and is highlighted with a gray background and an `Archived` tag in the bottom +right in the T4C instance overview. Existing linked T4C models and all +repositories corresponding to the archived instance will continue to work as +before. diff --git a/docs/user/mkdocs.yml b/docs/user/mkdocs.yml index 11362b611..fa3c9652e 100644 --- a/docs/user/mkdocs.yml +++ b/docs/user/mkdocs.yml @@ -53,6 +53,7 @@ nav: - pure::variants: settings/tools/pure_variants.md - Model sources: - Git: settings/model-sources/git.md + - T4C: settings/model-sources/t4c.md - Alerts: - Create an alert: additional/alerts/create.md - Delete an alert: additional/alerts/delete.md @@ -92,4 +93,6 @@ markdown_extensions: extra: generator: false +dev_addr: 127.0.0.1:8081 + copyright: Copyright © 2022-2023 DB Netz AG diff --git a/frontend/src/app/projects/models/model-source/t4c/manage-t4c-model/manage-t4c-model.component.html b/frontend/src/app/projects/models/model-source/t4c/manage-t4c-model/manage-t4c-model.component.html index c3cb95aad..7a4d27a24 100644 --- a/frontend/src/app/projects/models/model-source/t4c/manage-t4c-model/manage-t4c-model.component.html +++ b/frontend/src/app/projects/models/model-source/t4c/manage-t4c-model/manage-t4c-model.component.html @@ -24,6 +24,10 @@ {{ instance.name }} diff --git a/frontend/src/app/services/settings/t4c-instance.service.ts b/frontend/src/app/services/settings/t4c-instance.service.ts index 547fd4e94..6313d331e 100644 --- a/frontend/src/app/services/settings/t4c-instance.service.ts +++ b/frontend/src/app/services/settings/t4c-instance.service.ts @@ -5,7 +5,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, tap } from 'rxjs'; +import { BehaviorSubject, Observable, map, tap } from 'rxjs'; import { environment } from 'src/environments/environment'; export type Protocol = 'tcp' | 'ssl' | 'ws' | 'wss'; @@ -22,8 +22,11 @@ export type BaseT4CInstance = { username: string; password: string; protocol: Protocol; + is_archived: boolean; }; +export type PatchT4CInstance = Partial; + export type NewT4CInstance = BaseT4CInstance & { name: string; }; @@ -58,6 +61,12 @@ export class T4CInstanceService { ); public readonly t4cInstance$ = this._t4cInstance.asObservable(); + public readonly unarchivedT4cInstances$ = this._t4cInstances.pipe( + map((t4cInstances) => + t4cInstances?.filter((t4cInstance) => !t4cInstance.is_archived) + ) + ); + loadInstances(): void { this.http.get(this.baseUrl).subscribe({ next: (instances) => this._t4cInstances.next(instances), @@ -83,7 +92,7 @@ export class T4CInstanceService { updateInstance( instanceId: number, - instance: BaseT4CInstance + instance: PatchT4CInstance ): Observable { return this.http .patch(this.urlFactory(instanceId), instance) diff --git a/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.html b/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.html index c441f7e62..28389af23 100644 --- a/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.html +++ b/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.html @@ -176,9 +176,27 @@

Add a Team4Capella instance

-
- +
+ +
+
+
diff --git a/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.ts b/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.ts index 150c3a698..12377e2c5 100644 --- a/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.ts +++ b/frontend/src/app/settings/modelsources/t4c-settings/edit-t4c-instance/edit-t4c-instance.component.ts @@ -11,8 +11,8 @@ import { filter, map } from 'rxjs'; import { BreadcrumbsService } from 'src/app/general/breadcrumbs/breadcrumbs.service'; import { ToastService } from 'src/app/helpers/toast/toast.service'; import { - BaseT4CInstance, NewT4CInstance, + PatchT4CInstance, Protocol, T4CInstanceService, } from 'src/app/services/settings/t4c-instance.service'; @@ -34,6 +34,8 @@ export class EditT4CInstanceComponent implements OnInit, OnDestroy { instanceId?: number; capellaVersions?: ToolVersion[]; + isArchived?: boolean; + portValidators = [ Validators.pattern(/^\d*$/), Validators.min(0), @@ -91,6 +93,7 @@ export class EditT4CInstanceComponent implements OnInit, OnDestroy { .pipe(untilDestroyed(this), filter(Boolean)) .subscribe((t4cInstance) => { t4cInstance.password = '***********'; + this.isArchived = t4cInstance.is_archived; this.form.patchValue(t4cInstance); this.breadcrumbsService.updatePlaceholder({ t4cInstance }); }); @@ -139,7 +142,7 @@ export class EditT4CInstanceComponent implements OnInit, OnDestroy { update(): void { if (this.form.valid && this.instanceId) { this.t4cInstanceService - .updateInstance(this.instanceId, this.form.value as BaseT4CInstance) + .updateInstance(this.instanceId, this.form.value as PatchT4CInstance) .subscribe((instance) => { this.editing = false; this.form.disable(); @@ -151,6 +154,24 @@ export class EditT4CInstanceComponent implements OnInit, OnDestroy { } } + toggleArchive(): void { + if (this.instanceId) { + this.t4cInstanceService + .updateInstance(this.instanceId, { + is_archived: !this.isArchived, + }) + .subscribe((instance) => { + this.isArchived = instance.is_archived; + this.toastService.showSuccess( + 'Instance updated', + `The instance “${instance.name}” is now ${ + this.isArchived ? 'archived' : 'unarchived' + }.` + ); + }); + } + } + submit(): void { if (this.existing) { this.update(); diff --git a/frontend/src/app/settings/modelsources/t4c-settings/t4c-settings.component.html b/frontend/src/app/settings/modelsources/t4c-settings/t4c-settings.component.html index 804c43b30..cce15fc2e 100644 --- a/frontend/src/app/settings/modelsources/t4c-settings/t4c-settings.component.html +++ b/frontend/src/app/settings/modelsources/t4c-settings/t4c-settings.component.html @@ -19,7 +19,11 @@ *ngFor="let instance of t4cInstanceService.t4cInstances$ | async" [routerLink]="['instance', instance.id]" > - +
{{ instance.name }}
tag Capella version: @@ -28,6 +32,13 @@ link Host: {{ instance.protocol }}://{{ instance.host }}:{{ instance.port }}
+ +
+ Archived +