diff --git a/backend/capellacollab/alembic/versions/320c5b39c509_add_beta_tester.py b/backend/capellacollab/alembic/versions/320c5b39c509_add_beta_tester.py new file mode 100644 index 0000000000..0adc3cc5b8 --- /dev/null +++ b/backend/capellacollab/alembic/versions/320c5b39c509_add_beta_tester.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add Beta Tester + +Revision ID: 320c5b39c509 +Revises: 3818a5009130 +Create Date: 2024-11-04 12:31:17.024627 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "320c5b39c509" +down_revision = "3818a5009130" +branch_labels = None +depends_on = None + + +t_tool_versions = sa.Table( + "versions", + sa.MetaData(), + sa.Column("id", sa.Integer()), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text())), +) + + +def upgrade(): + op.add_column( + "users", + sa.Column( + "beta_tester", sa.Boolean(), nullable=False, server_default="false" + ), + ) + + op.add_column( + "feedback", + sa.Column( + "beta_tester", sa.Boolean(), nullable=False, server_default="false" + ), + ) + + connection = op.get_bind() + results = connection.execute(sa.select(t_tool_versions)).mappings().all() + + for row in results: + config = row["config"] + config["sessions"]["persistent"]["image"] = { + "default": config["sessions"]["persistent"]["image"], + "beta": None, + } + + connection.execute( + sa.update(t_tool_versions) + .where(t_tool_versions.c.id == row["id"]) + .values(config=config) + ) diff --git a/backend/capellacollab/core/database/migration.py b/backend/capellacollab/core/database/migration.py index 985617a7a9..01c22e29bf 100644 --- a/backend/capellacollab/core/database/migration.py +++ b/backend/capellacollab/core/database/migration.py @@ -209,7 +209,10 @@ def create_capella_tool(db: orm.Session) -> tools_models.DatabaseTool: is_deprecated=capella_version_name in ("5.0.0", "5.2.0"), sessions=tools_models.SessionToolConfiguration( persistent=tools_models.PersistentSessionToolConfiguration( - image=(f"{registry}/capella/remote:{docker_tag}"), + image=tools_models.PersistentSessionToolConfigurationImages( + regular=f"{registry}/capella/remote:{docker_tag}", + beta=None, + ), ), ), backups=tools_models.ToolBackupConfiguration( @@ -247,8 +250,9 @@ def create_papyrus_tool(db: orm.Session) -> tools_models.DatabaseTool: is_deprecated=False, sessions=tools_models.SessionToolConfiguration( persistent=tools_models.PersistentSessionToolConfiguration( - image=( - f"{config.docker.sessions_registry}/capella/remote:{papyrus_version_name}-latest" + image=tools_models.PersistentSessionToolConfigurationImages( + regular=f"{config.docker.sessions_registry}/papyrus/remote:{papyrus_version_name}-latest", + beta=None, ), ), ), @@ -329,8 +333,9 @@ def create_jupyter_tool(db: orm.Session) -> tools_models.DatabaseTool: is_deprecated=False, sessions=tools_models.SessionToolConfiguration( persistent=tools_models.PersistentSessionToolConfiguration( - image=( - f"{config.docker.sessions_registry}/jupyter-notebook:python-3.11" + image=tools_models.PersistentSessionToolConfigurationImages( + regular=f"{config.docker.sessions_registry}/jupyter-notebook:python-3.11", + beta=None, ), ), ), diff --git a/backend/capellacollab/feedback/crud.py b/backend/capellacollab/feedback/crud.py index 6184bd4a2a..9f00b3e6bd 100644 --- a/backend/capellacollab/feedback/crud.py +++ b/backend/capellacollab/feedback/crud.py @@ -46,6 +46,7 @@ def save_feedback( model = models.DatabaseFeedback( rating=rating, user=user, + beta_tester=user.beta_tester if user else False, feedback_text=feedback_text, created_at=created_at, trigger=trigger, diff --git a/backend/capellacollab/feedback/models.py b/backend/capellacollab/feedback/models.py index 23139282d5..1ffd0a158d 100644 --- a/backend/capellacollab/feedback/models.py +++ b/backend/capellacollab/feedback/models.py @@ -40,6 +40,11 @@ class DatabaseFeedback(database.Base): cascade="all, delete-orphan", single_parent=True, ) + beta_tester: orm.Mapped[bool] = orm.mapped_column( + sa.Boolean, + nullable=False, + server_default="false", + ) trigger: orm.Mapped[str | None] created_at: orm.Mapped[datetime.datetime] diff --git a/backend/capellacollab/feedback/util.py b/backend/capellacollab/feedback/util.py index 61844b61c1..e0d5e84ee6 100644 --- a/backend/capellacollab/feedback/util.py +++ b/backend/capellacollab/feedback/util.py @@ -65,6 +65,11 @@ def format_email( f"User: {user_msg}", f"User Agent: {user_agent or 'Unknown'}", ] + if user: + message_list.append( + f"Beta Tester: {user.beta_tester}", + ) + if feedback.trigger: message_list.append(f"Trigger: {feedback.trigger}") diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 7ee7908701..545b56f4cb 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -165,7 +165,9 @@ def request_session( warnings += local_warnings environment |= local_env - docker_image = util.get_docker_image(version, body.session_type) + docker_image = util.get_docker_image( + version, body.session_type, user.beta_tester + ) annotations: dict[str, str] = { "capellacollab/owner-name": user.name, diff --git a/backend/capellacollab/sessions/util.py b/backend/capellacollab/sessions/util.py index 8abe6876f0..60c8a69855 100644 --- a/backend/capellacollab/sessions/util.py +++ b/backend/capellacollab/sessions/util.py @@ -171,10 +171,13 @@ def stringify_environment_variables( def get_docker_image( - version: tools_models.DatabaseVersion, workspace_type: models.SessionType + version: tools_models.DatabaseVersion, + workspace_type: models.SessionType, + beta: bool, ) -> str: """Get the Docker image for a given tool version and workspace type""" - template = version.config.sessions.persistent.image + images = version.config.sessions.persistent.image + template = images.beta if beta and images.beta else images.regular if not template: raise exceptions.UnsupportedSessionTypeError( diff --git a/backend/capellacollab/settings/configuration/models.py b/backend/capellacollab/settings/configuration/models.py index ba8d0631cc..6382332c06 100644 --- a/backend/capellacollab/settings/configuration/models.py +++ b/backend/capellacollab/settings/configuration/models.py @@ -159,6 +159,17 @@ class FeedbackConfiguration(core_pydantic.BaseModelStrict): ) +class BetaConfiguration(core_pydantic.BaseModelStrict): + enabled: bool = pydantic.Field( + default=core.DEVELOPMENT_MODE, + description="Enable beta-testing features. Disabling this will un-enroll all beta-testers.", + ) + allow_self_enrollment: bool = pydantic.Field( + default=core.DEVELOPMENT_MODE, + description="Allow users to register themselves as beta-testers.", + ) + + class ConfigurationBase(core_pydantic.BaseModelStrict, abc.ABC): """ Base class for configuration models. Can be used to define new configurations @@ -217,6 +228,8 @@ class GlobalConfiguration(ConfigurationBase): default_factory=FeedbackConfiguration ) + beta: BetaConfiguration = pydantic.Field(default_factory=BetaConfiguration) + pipelines: PipelineConfiguration = pydantic.Field( default_factory=PipelineConfiguration ) diff --git a/backend/capellacollab/settings/configuration/routes.py b/backend/capellacollab/settings/configuration/routes.py index 6f0bc593a7..7690fc19a2 100644 --- a/backend/capellacollab/settings/configuration/routes.py +++ b/backend/capellacollab/settings/configuration/routes.py @@ -9,6 +9,7 @@ from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.feedback import util as feedback_util +from capellacollab.users import crud as users_crud from capellacollab.users import models as users_models from . import core, crud, models @@ -50,6 +51,10 @@ async def update_configuration( ) feedback_util.validate_global_configuration(body.feedback) + + if body.beta.enabled is False: + users_crud.unenroll_all_beta_testers(db) + if body.feedback.enabled is False: feedback_util.disable_feedback(body.feedback) diff --git a/backend/capellacollab/tools/models.py b/backend/capellacollab/tools/models.py index 33116c077c..46e506b79c 100644 --- a/backend/capellacollab/tools/models.py +++ b/backend/capellacollab/tools/models.py @@ -359,8 +359,8 @@ class DatabaseTool(database.Base): ) -class PersistentSessionToolConfiguration(core_pydantic.BaseModel): - image: str | None = pydantic.Field( +class PersistentSessionToolConfigurationImages(core_pydantic.BaseModel): + regular: str | None = pydantic.Field( default="docker.io/hello-world:latest", pattern=DOCKER_IMAGE_PATTERN, examples=[ @@ -374,6 +374,25 @@ class PersistentSessionToolConfiguration(core_pydantic.BaseModel): "Always use tags to prevent breaking updates. " ), ) + beta: str | None = pydantic.Field( + default=None, + pattern=DOCKER_IMAGE_PATTERN, + examples=[ + "docker.io/hello-world:latest", + "ghcr.io/dsd-dbs/capella-dockerimages/capella/remote:{version}-main", + ], + description=( + "Docker image, which is used for persistent sessions of beta users." + " If set to None, the regular image will be used instead." + " You can use '{version}' in the image, which will be replaced with the version name of the tool." + ), + ) + + +class PersistentSessionToolConfiguration(core_pydantic.BaseModel): + image: PersistentSessionToolConfigurationImages = pydantic.Field( + default=PersistentSessionToolConfigurationImages() + ) class ToolBackupConfiguration(core_pydantic.BaseModel): diff --git a/backend/capellacollab/users/crud.py b/backend/capellacollab/users/crud.py index 08d5aa006e..6d855b7508 100644 --- a/backend/capellacollab/users/crud.py +++ b/backend/capellacollab/users/crud.py @@ -108,3 +108,12 @@ def update_last_login( def delete_user(db: orm.Session, user: models.DatabaseUser): db.delete(user) db.commit() + + +def unenroll_all_beta_testers(db: orm.Session): + db.execute( + sa.update(models.DatabaseUser) + .where(models.DatabaseUser.beta_tester.is_(True)) + .values(beta_tester=False) + ) + db.commit() diff --git a/backend/capellacollab/users/exceptions.py b/backend/capellacollab/users/exceptions.py index 4ab7b8879c..b64e4ce4be 100644 --- a/backend/capellacollab/users/exceptions.py +++ b/backend/capellacollab/users/exceptions.py @@ -38,3 +38,43 @@ def __init__(self): reason=("You must provide a reason for updating the users roles."), err_code="ROLE_UPDATE_REQUIRES_REASON", ) + + +class ChangesNotAllowedForOtherUsersError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="You cannot make changes for other users", + reason="Your role does not allow you to make changes for other users.", + err_code="CHANGES_NOT_ALLOWED_FOR_OTHER_USERS", + ) + + +class ChangesNotAllowedForRoleError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="Changes not allowed for role", + reason="Your role does not allow you to make these changes.", + err_code="CHANGES_NOT_ALLOWED_FOR_ROLE", + ) + + +class BetaTestingDisabledError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="Beta testing disabled", + reason="Beta testing is currently disabled.", + err_code="BETA_TESTING_DISABLED", + ) + + +class BetaTestingSelfEnrollmentNotAllowedError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="Beta testing self enrollment not allowed", + reason="You do not have permission to enroll yourself in beta testing.", + err_code="BETA_TESTING_SELF_ENROLLMENT_NOT_ALLOWED", + ) diff --git a/backend/capellacollab/users/models.py b/backend/capellacollab/users/models.py index 24660736d6..74bf9fb000 100644 --- a/backend/capellacollab/users/models.py +++ b/backend/capellacollab/users/models.py @@ -31,6 +31,7 @@ class BaseUser(core_pydantic.BaseModel): idp_identifier: str email: str | None = None role: Role + beta_tester: bool = False class User(BaseUser): @@ -51,6 +52,7 @@ class PatchUser(core_pydantic.BaseModel): email: str | None = None role: Role | None = None reason: str | None = None + beta_tester: bool | None = None class PostUser(core_pydantic.BaseModel): @@ -59,6 +61,7 @@ class PostUser(core_pydantic.BaseModel): email: str | None = None role: Role reason: str + beta_tester: bool = False class DatabaseUser(database.Base): @@ -104,3 +107,5 @@ class DatabaseUser(database.Base): last_login: orm.Mapped[datetime.datetime | None] = orm.mapped_column( default=None ) + + beta_tester: orm.Mapped[bool] = orm.mapped_column(default=False) diff --git a/backend/capellacollab/users/routes.py b/backend/capellacollab/users/routes.py index 51c8bdbf59..1ce274724f 100644 --- a/backend/capellacollab/users/routes.py +++ b/backend/capellacollab/users/routes.py @@ -15,6 +15,8 @@ from capellacollab.projects import models as projects_models from capellacollab.projects.users import crud as projects_users_crud from capellacollab.sessions import routes as session_routes +from capellacollab.settings.configuration import core as config_core +from capellacollab.settings.configuration import models as config_models from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models from capellacollab.users.tokens import routes as tokens_routes @@ -39,6 +41,18 @@ def get_current_user( return user +@router.get( + "/beta", + response_model=config_models.BetaConfiguration, +) +def get_beta_config(db: orm.Session = fastapi.Depends(database.get_db)): + cfg = config_core.get_global_configuration(db) + + return config_models.BetaConfiguration.model_validate( + cfg.beta.model_dump() + ) + + @router.get( "/{user_id}", response_model=models.User, @@ -125,11 +139,6 @@ def get_common_projects( @router.patch( "/{user_id}", response_model=models.User, - dependencies=[ - fastapi.Depends( - auth_injectables.RoleVerification(required_role=models.Role.ADMIN) - ) - ], ) def update_user( patch_user: models.PatchUser, @@ -137,6 +146,23 @@ def update_user( own_user: models.DatabaseUser = fastapi.Depends(get_current_user), db: orm.Session = fastapi.Depends(database.get_db), ): + # Users are only allowed to update their beta_tester status unless they are an admin + if own_user.role != models.Role.ADMIN: + if own_user.id != user.id: + raise exceptions.ChangesNotAllowedForOtherUsersError() + if any(patch_user.model_dump(exclude={"beta_tester"}).values()): + raise exceptions.ChangesNotAllowedForRoleError() + + if patch_user.beta_tester: + cfg = config_core.get_global_configuration(db) + if not cfg.beta.enabled: + raise exceptions.BetaTestingDisabledError() + if ( + not cfg.beta.allow_self_enrollment + and own_user.role != models.Role.ADMIN + ): + raise exceptions.BetaTestingSelfEnrollmentNotAllowedError() + if patch_user.role and patch_user.role != user.role: reason = patch_user.reason if not reason: diff --git a/backend/tests/test_beta.py b/backend/tests/test_beta.py new file mode 100644 index 0000000000..029ae5fb94 --- /dev/null +++ b/backend/tests/test_beta.py @@ -0,0 +1,146 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + + +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.settings.configuration import crud as configuration_crud +from capellacollab.users import crud as users_crud +from capellacollab.users import models as users_models + + +def test_set_own_beta_status_beta_disabled( + client: testclient.TestClient, + user: users_models.DatabaseUser, + db: orm.Session, +): + """ + Fail setting own beta status because beta is disabled + """ + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": False, "allow_self_enrollment": False}}, + ) + response = client.patch( + f"/api/v1/users/{user.id}", json={"beta_tester": True} + ) + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "BETA_TESTING_DISABLED" + + +def test_set_own_beta_status_no_self_enrollment( + client: testclient.TestClient, + user: users_models.DatabaseUser, + db: orm.Session, +): + """ + Fail setting own beta status because self serve beta is disabled + """ + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": True, "allow_self_enrollment": False}}, + ) + response = client.patch( + f"/api/v1/users/{user.id}", json={"beta_tester": True} + ) + assert response.status_code == 403 + assert ( + response.json()["detail"]["err_code"] + == "BETA_TESTING_SELF_ENROLLMENT_NOT_ALLOWED" + ) + + +def test_self_enroll_beta( + client: testclient.TestClient, + user: users_models.DatabaseUser, + db: orm.Session, +): + """ + Successfully set own beta status + """ + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": True, "allow_self_enrollment": True}}, + ) + response = client.patch( + f"/api/v1/users/{user.id}", json={"beta_tester": True} + ) + assert response.status_code == 200 + + +def test_fail_enroll_other_people( + client: testclient.TestClient, + user: users_models.DatabaseUser, + db: orm.Session, +): + """ + Fail setting other people's beta status because not an admin + """ + user2 = users_crud.create_user(db, "user2", "user2") + + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": True, "allow_self_enrollment": True}}, + ) + response = client.patch( + f"/api/v1/users/{user2.id}", json={"beta_tester": True} + ) + assert response.status_code == 403 + assert ( + response.json()["detail"]["err_code"] + == "CHANGES_NOT_ALLOWED_FOR_OTHER_USERS" + ) + + +def test_admin_enroll_other_people( + client: testclient.TestClient, + admin: users_models.DatabaseUser, + db: orm.Session, +): + """ + Successfully set other people's beta status as an admin + """ + user2 = users_crud.create_user(db, "user2", "user2") + + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": True, "allow_self_enrollment": False}}, + ) + response = client.patch( + f"/api/v1/users/{user2.id}", json={"beta_tester": True} + ) + assert response.status_code == 200 + + +def test_disable_beta_un_enroll( + client: testclient.TestClient, + admin: users_models.DatabaseUser, + db: orm.Session, +): + """ + When beta is disabled, all users should be unenrolled + """ + configuration_crud.create_configuration( + db, + "global", + {"beta": {"enabled": True, "allow_self_enrollment": False}}, + ) + response = client.patch( + f"/api/v1/users/{admin.id}", json={"beta_tester": True} + ) + assert response.status_code == 200 + + client.put( + "/api/v1/settings/configurations/global", + json={"beta": {"enabled": False}}, + ) + + response = client.get(f"/api/v1/users/{admin.id}") + assert response.status_code == 200 + assert response.json()["beta_tester"] is False diff --git a/backend/tests/tools/versions/test_tools_version_routes.py b/backend/tests/tools/versions/test_tools_version_routes.py index e8627d44d3..86c436dc56 100644 --- a/backend/tests/tools/versions/test_tools_version_routes.py +++ b/backend/tests/tools/versions/test_tools_version_routes.py @@ -21,8 +21,12 @@ def test_create_tool_version( "is_recommended": False, "is_deprecated": False, "sessions": { - "persistent": {"image": "docker.io/hello-world:latest"}, - "read_only": {"image": "docker.io/hello-world:latest"}, + "persistent": { + "image": { + "default": "docker.io/hello-world:latest", + "beta": None, + } + }, }, "backups": {"image": "docker.io/hello-world:latest"}, }, @@ -78,8 +82,12 @@ def test_update_tools_version( "is_recommended": False, "is_deprecated": False, "sessions": { - "persistent": {"image": "docker.io/hello-world:latest"}, - "read_only": {"image": "docker.io/hello-world:latest"}, + "persistent": { + "image": { + "default": "docker.io/hello-world:latest", + "beta": None, + } + }, }, "backups": {"image": "docker.io/hello-world:latest"}, }, diff --git a/backend/tests/users/test_users.py b/backend/tests/users/test_users.py index b7c58d7adc..a4f7e09d3e 100644 --- a/backend/tests/users/test_users.py +++ b/backend/tests/users/test_users.py @@ -129,3 +129,16 @@ def test_delete_user( ) is None ) + + +def test_fail_update_own_user( + client: testclient.TestClient, user: users_models.DatabaseUser +): + response = client.patch( + f"/api/v1/users/{user.id}", json={"name": "new_name"} + ) + + assert response.status_code == 403 + assert ( + response.json()["detail"]["err_code"] == "CHANGES_NOT_ALLOWED_FOR_ROLE" + ) diff --git a/docs/docs/admin/configure-for-your-org.md b/docs/docs/admin/configure-for-your-org.md index 099759671a..774c6866c2 100644 --- a/docs/docs/admin/configure-for-your-org.md +++ b/docs/docs/admin/configure-for-your-org.md @@ -101,3 +101,16 @@ Prompts that are associated with a session automatically include anonymized metadata about the session. 1. Feedback will be sent by email to all addresses specified here. + +## Beta-Testing + +To test new images, you can enable the beta-testing feature. This will allow +you specify a different image tag to use. Users can self-enroll as a +beta-tester if you enable self-enrollment. Admins can always enroll themselves +and others. + +```yaml +beta: + enabled: true + allow_self_enrollment: true +``` diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index 78d68d43ae..c6482e0fe5 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -35,6 +35,8 @@ model/authorization-response.ts model/backup-pipeline-run.ts model/backup.ts model/base-user.ts +model/beta-configuration-input.ts +model/beta-configuration-output.ts model/built-in-link-item.ts model/built-in-navbar-link.ts model/cpu-resources-input.ts @@ -108,6 +110,8 @@ model/patch-user.ts model/path-validation.ts model/payload-response-model-list-simple-t4-c-repository-with-integrations.ts model/payload-response-model-session-connection-information.ts +model/persistent-session-tool-configuration-images-input.ts +model/persistent-session-tool-configuration-images-output.ts model/persistent-session-tool-configuration-input.ts model/persistent-session-tool-configuration-output.ts model/persistent-workspace-session-configuration-input.ts diff --git a/frontend/src/app/openapi/api/users.service.ts b/frontend/src/app/openapi/api/users.service.ts index 9495cfdf00..45b107b2af 100644 --- a/frontend/src/app/openapi/api/users.service.ts +++ b/frontend/src/app/openapi/api/users.service.ts @@ -18,6 +18,8 @@ import { HttpClient, HttpHeaders, HttpParams, import { CustomHttpParameterCodec } from '../encoder'; import { Observable } from 'rxjs'; +// @ts-ignore +import { BetaConfigurationOutput } from '../model/beta-configuration-output'; // @ts-ignore import { HTTPValidationError } from '../model/http-validation-error'; // @ts-ignore @@ -558,6 +560,73 @@ export class UsersService { ); } + /** + * Get Beta Config + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getBetaConfig(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getBetaConfig(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getBetaConfig(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getBetaConfig(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/users/beta`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + /** * Get Common Projects * @param userId diff --git a/frontend/src/app/openapi/model/base-user.ts b/frontend/src/app/openapi/model/base-user.ts index 8fce88990b..b2103d5f68 100644 --- a/frontend/src/app/openapi/model/base-user.ts +++ b/frontend/src/app/openapi/model/base-user.ts @@ -18,6 +18,7 @@ export interface BaseUser { idp_identifier: string; email: string | null; role: Role; + beta_tester: boolean; } export namespace BaseUser { } diff --git a/frontend/src/app/openapi/model/beta-configuration-input.ts b/frontend/src/app/openapi/model/beta-configuration-input.ts new file mode 100644 index 0000000000..641edef2bc --- /dev/null +++ b/frontend/src/app/openapi/model/beta-configuration-input.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface BetaConfigurationInput { + /** + * Enable beta-testing features. Disabling this will un-enroll all beta-testers. + */ + enabled?: boolean; + /** + * Allow users to register themselves as beta-testers. + */ + allow_self_enrollment?: boolean; +} + diff --git a/frontend/src/app/openapi/model/beta-configuration-output.ts b/frontend/src/app/openapi/model/beta-configuration-output.ts new file mode 100644 index 0000000000..3f8a5f0896 --- /dev/null +++ b/frontend/src/app/openapi/model/beta-configuration-output.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface BetaConfigurationOutput { + /** + * Enable beta-testing features. Disabling this will un-enroll all beta-testers. + */ + enabled: boolean; + /** + * Allow users to register themselves as beta-testers. + */ + allow_self_enrollment: boolean; +} + diff --git a/frontend/src/app/openapi/model/global-configuration-input.ts b/frontend/src/app/openapi/model/global-configuration-input.ts index 8306d95b04..b71ad7bfe3 100644 --- a/frontend/src/app/openapi/model/global-configuration-input.ts +++ b/frontend/src/app/openapi/model/global-configuration-input.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { BetaConfigurationInput } from './beta-configuration-input'; import { NavbarConfigurationInput } from './navbar-configuration-input'; import { MetadataConfigurationInput } from './metadata-configuration-input'; import { PipelineConfigurationInput } from './pipeline-configuration-input'; @@ -22,6 +23,7 @@ export interface GlobalConfigurationInput { metadata?: MetadataConfigurationInput; navbar?: NavbarConfigurationInput; feedback?: FeedbackConfigurationInput; + beta?: BetaConfigurationInput; pipelines?: PipelineConfigurationInput; } diff --git a/frontend/src/app/openapi/model/global-configuration-output.ts b/frontend/src/app/openapi/model/global-configuration-output.ts index 95c90653a8..33192ad5d9 100644 --- a/frontend/src/app/openapi/model/global-configuration-output.ts +++ b/frontend/src/app/openapi/model/global-configuration-output.ts @@ -12,6 +12,7 @@ import { NavbarConfigurationOutput } from './navbar-configuration-output'; import { MetadataConfigurationOutput } from './metadata-configuration-output'; import { PipelineConfigurationOutput } from './pipeline-configuration-output'; +import { BetaConfigurationOutput } from './beta-configuration-output'; import { FeedbackConfigurationOutput } from './feedback-configuration-output'; @@ -22,6 +23,7 @@ export interface GlobalConfigurationOutput { metadata: MetadataConfigurationOutput; navbar: NavbarConfigurationOutput; feedback: FeedbackConfigurationOutput; + beta: BetaConfigurationOutput; pipelines: PipelineConfigurationOutput; } diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index 3a259984a2..9c61980e98 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -14,6 +14,8 @@ export * from './authorization-response'; export * from './backup'; export * from './backup-pipeline-run'; export * from './base-user'; +export * from './beta-configuration-input'; +export * from './beta-configuration-output'; export * from './built-in-link-item'; export * from './built-in-navbar-link'; export * from './cpu-resources-input'; @@ -86,6 +88,8 @@ export * from './patch-user'; export * from './path-validation'; export * from './payload-response-model-list-simple-t4-c-repository-with-integrations'; export * from './payload-response-model-session-connection-information'; +export * from './persistent-session-tool-configuration-images-input'; +export * from './persistent-session-tool-configuration-images-output'; export * from './persistent-session-tool-configuration-input'; export * from './persistent-session-tool-configuration-output'; export * from './persistent-workspace-session-configuration-input'; diff --git a/frontend/src/app/openapi/model/patch-user.ts b/frontend/src/app/openapi/model/patch-user.ts index 7a774eb6ae..771557f428 100644 --- a/frontend/src/app/openapi/model/patch-user.ts +++ b/frontend/src/app/openapi/model/patch-user.ts @@ -18,6 +18,7 @@ export interface PatchUser { email?: string | null; role?: Role | null; reason?: string | null; + beta_tester?: boolean | null; } export namespace PatchUser { } diff --git a/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-input.ts b/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-input.ts new file mode 100644 index 0000000000..9f2f88ab79 --- /dev/null +++ b/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-input.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface PersistentSessionToolConfigurationImagesInput { + regular?: string | null; + beta?: string | null; +} + diff --git a/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-output.ts b/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-output.ts new file mode 100644 index 0000000000..ecb01b6458 --- /dev/null +++ b/frontend/src/app/openapi/model/persistent-session-tool-configuration-images-output.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface PersistentSessionToolConfigurationImagesOutput { + regular: string | null; + beta: string | null; +} + diff --git a/frontend/src/app/openapi/model/persistent-session-tool-configuration-input.ts b/frontend/src/app/openapi/model/persistent-session-tool-configuration-input.ts index c92cafe6bc..3ddda33238 100644 --- a/frontend/src/app/openapi/model/persistent-session-tool-configuration-input.ts +++ b/frontend/src/app/openapi/model/persistent-session-tool-configuration-input.ts @@ -9,9 +9,10 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { PersistentSessionToolConfigurationImagesInput } from './persistent-session-tool-configuration-images-input'; export interface PersistentSessionToolConfigurationInput { - image?: string | null; + image?: PersistentSessionToolConfigurationImagesInput; } diff --git a/frontend/src/app/openapi/model/persistent-session-tool-configuration-output.ts b/frontend/src/app/openapi/model/persistent-session-tool-configuration-output.ts index 421a4810d4..74307e9668 100644 --- a/frontend/src/app/openapi/model/persistent-session-tool-configuration-output.ts +++ b/frontend/src/app/openapi/model/persistent-session-tool-configuration-output.ts @@ -9,9 +9,10 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { PersistentSessionToolConfigurationImagesOutput } from './persistent-session-tool-configuration-images-output'; export interface PersistentSessionToolConfigurationOutput { - image: string | null; + image: PersistentSessionToolConfigurationImagesOutput; } diff --git a/frontend/src/app/openapi/model/post-user.ts b/frontend/src/app/openapi/model/post-user.ts index 7c7787dfca..3900548a22 100644 --- a/frontend/src/app/openapi/model/post-user.ts +++ b/frontend/src/app/openapi/model/post-user.ts @@ -18,6 +18,7 @@ export interface PostUser { email?: string | null; role: Role; reason: string; + beta_tester?: boolean; } export namespace PostUser { } diff --git a/frontend/src/app/openapi/model/user.ts b/frontend/src/app/openapi/model/user.ts index ca7bd058ce..b85bc33a35 100644 --- a/frontend/src/app/openapi/model/user.ts +++ b/frontend/src/app/openapi/model/user.ts @@ -18,6 +18,7 @@ export interface User { idp_identifier: string; email: string | null; role: Role; + beta_tester: boolean; created: string | null; last_login: string | null; } diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts index 4a0b9a8a26..e667e6d3af 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts @@ -326,6 +326,7 @@ export const SessionSharedWithUser: Story = { role: 'administrator', email: null, idp_identifier: 'user_1', + beta_tester: false, }, created_at: '2024-04-29T15:00:00Z', }, @@ -336,6 +337,7 @@ export const SessionSharedWithUser: Story = { role: 'user', email: null, idp_identifier: 'user_2', + beta_tester: false, }, created_at: '2024-04-29T15:00:00Z', }, diff --git a/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.html b/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.html new file mode 100644 index 0000000000..d8bc42f7f0 --- /dev/null +++ b/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.html @@ -0,0 +1,30 @@ + + +@if (this.userWrapperService.user$ | async; as user) { +
+

Try experimental features

+
+

+ Be one of the first to try our new updates! This may include new versions + of our docker images, experimental features, changes to the user + interface, and more.
+ If you run into any issues, please let us know using the feedback form. + You can opt-out at any time. +

+ +
+ @if (isBetaTester$ | async) { + + } @else { + + } +
+
+} diff --git a/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.ts b/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.ts new file mode 100644 index 0000000000..6e73f216dc --- /dev/null +++ b/frontend/src/app/users/users-profile/beta-testing/beta-testing.component.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { map, take } from 'rxjs'; +import { UsersService } from '../../../openapi'; +import { UserWrapperService } from '../../user-wrapper/user-wrapper.service'; + +@Component({ + selector: 'app-beta-testing', + standalone: true, + imports: [MatButton, AsyncPipe, MatIcon], + templateUrl: './beta-testing.component.html', +}) +export class BetaTestingComponent { + constructor( + public userWrapperService: UserWrapperService, + private usersService: UsersService, + ) {} + + readonly isBetaTester$ = this.userWrapperService.user$.pipe( + map((user) => user?.beta_tester ?? false), + ); + + setBetaTester(isBetaTester: boolean) { + this.userWrapperService.user$.pipe(take(1)).subscribe((user) => { + if (user) { + this.usersService + .updateUser(user.id, { beta_tester: isBetaTester }) + .subscribe(() => { + this.userWrapperService.loadUser(user.id); + }); + } + }); + } +} diff --git a/frontend/src/app/users/users-profile/beta-testing/beta-testing.service.ts b/frontend/src/app/users/users-profile/beta-testing/beta-testing.service.ts new file mode 100644 index 0000000000..3d79470933 --- /dev/null +++ b/frontend/src/app/users/users-profile/beta-testing/beta-testing.service.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Injectable } from '@angular/core'; +import { map } from 'rxjs'; +import { OwnUserWrapperService } from '../../../services/user/user.service'; + +@Injectable({ + providedIn: 'root', +}) +export class BetaTestingService { + constructor(public userService: OwnUserWrapperService) {} + + readonly isBetaTester$ = this.userService.user$.pipe( + map((user) => user?.beta_tester ?? false), + ); +} diff --git a/frontend/src/app/users/users-profile/beta-testing/beta-testing.stories.ts b/frontend/src/app/users/users-profile/beta-testing/beta-testing.stories.ts new file mode 100644 index 0000000000..65059b598e --- /dev/null +++ b/frontend/src/app/users/users-profile/beta-testing/beta-testing.stories.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { + MockOwnUserWrapperService, + mockUser, + MockUserWrapperService, +} from '../../../../storybook/user'; +import { OwnUserWrapperService } from '../../../services/user/user.service'; +import { UserWrapperService } from '../../user-wrapper/user-wrapper.service'; +import { BetaTestingComponent } from './beta-testing.component'; + +const meta: Meta = { + title: 'Settings Components/Users Profile/Beta Testing', + component: BetaTestingComponent, +}; + +export default meta; +type Story = StoryObj; + +export const OptIntoBeta: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: OwnUserWrapperService, + useFactory: () => new MockOwnUserWrapperService(mockUser), + }, + { + provide: UserWrapperService, + useFactory: () => new MockUserWrapperService(mockUser), + }, + ], + }), + ], +}; + +export const OptOutOfBeta: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: OwnUserWrapperService, + useFactory: () => new MockOwnUserWrapperService(mockUser), + }, + { + provide: UserWrapperService, + useFactory: () => + new MockUserWrapperService({ ...mockUser, beta_tester: true }), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/users/users-profile/users-profile.component.html b/frontend/src/app/users/users-profile/users-profile.component.html index 0315636f2f..3b1a24042f 100644 --- a/frontend/src/app/users/users-profile/users-profile.component.html +++ b/frontend/src/app/users/users-profile/users-profile.component.html @@ -27,6 +27,16 @@

Profile of {{ user?.name }}

@if (ownUserService.user?.role === "administrator") { } + @if ((betaConfig$ | async)?.enabled) { + @if ( + ownUserService.user?.role === "administrator" || + (ownUserService.user?.id === (userWrapperService.user$ | async)?.id && + (betaConfig$ | async)?.allow_self_enrollment) + ) { + + } + } + @if (ownUserService.user?.role === "administrator") { diff --git a/frontend/src/app/users/users-profile/users-profile.component.ts b/frontend/src/app/users/users-profile/users-profile.component.ts index 7b3a9e357a..34f3f09f4d 100644 --- a/frontend/src/app/users/users-profile/users-profile.component.ts +++ b/frontend/src/app/users/users-profile/users-profile.component.ts @@ -6,8 +6,11 @@ import { AsyncPipe, DatePipe } from '@angular/common'; import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; +import { BehaviorSubject } from 'rxjs'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; import { UserWrapperService } from 'src/app/users/user-wrapper/user-wrapper.service'; +import { BetaConfigurationOutput, UsersService } from '../../openapi'; +import { BetaTestingComponent } from './beta-testing/beta-testing.component'; import { CommonProjectsComponent } from './common-projects/common-projects.component'; import { UserInformationComponent } from './user-information/user-information.component'; import { UserWorkspacesComponent } from './user-workspaces/user-workspaces.component'; @@ -24,11 +27,25 @@ import { UserWorkspacesComponent } from './user-workspaces/user-workspaces.compo UserInformationComponent, UserWorkspacesComponent, AsyncPipe, + BetaTestingComponent, ], }) export class UsersProfileComponent { constructor( public ownUserService: OwnUserWrapperService, public userWrapperService: UserWrapperService, - ) {} + private usersService: UsersService, + ) { + this.getBetaConfig(); + } + + readonly betaConfig$ = new BehaviorSubject< + BetaConfigurationOutput | undefined + >(undefined); + + getBetaConfig() { + return this.usersService.getBetaConfig().subscribe((res) => { + this.betaConfig$.next(res); + }); + } } diff --git a/frontend/src/storybook/tool.ts b/frontend/src/storybook/tool.ts index 83aa2aadef..89ee955a1d 100644 --- a/frontend/src/storybook/tool.ts +++ b/frontend/src/storybook/tool.ts @@ -36,7 +36,10 @@ export const mockToolVersion: Readonly = { compatible_versions: [], sessions: { persistent: { - image: 'fakeImage', + image: { + regular: 'fakeImage', + beta: null, + }, }, }, backups: { diff --git a/frontend/src/storybook/user.ts b/frontend/src/storybook/user.ts index 3c3fb24019..27cdff136e 100644 --- a/frontend/src/storybook/user.ts +++ b/frontend/src/storybook/user.ts @@ -17,6 +17,7 @@ export const mockUser: Readonly = { role: 'user', created: '2024-04-29T14:00:00Z', last_login: '2024-04-29T14:59:00Z', + beta_tester: false, }; export class MockOwnUserWrapperService