Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow users to sign up as beta-testers #1959

Merged
merged 1 commit into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
)
15 changes: 10 additions & 5 deletions backend/capellacollab/core/database/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
),
),
),
Expand Down Expand Up @@ -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,
),
),
),
Expand Down
1 change: 1 addition & 0 deletions backend/capellacollab/feedback/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions backend/capellacollab/feedback/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions backend/capellacollab/feedback/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
4 changes: 3 additions & 1 deletion backend/capellacollab/sessions/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions backend/capellacollab/sessions/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions backend/capellacollab/settings/configuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,6 +228,8 @@ class GlobalConfiguration(ConfigurationBase):
default_factory=FeedbackConfiguration
)

beta: BetaConfiguration = pydantic.Field(default_factory=BetaConfiguration)

pipelines: PipelineConfiguration = pydantic.Field(
default_factory=PipelineConfiguration
)
Expand Down
5 changes: 5 additions & 0 deletions backend/capellacollab/settings/configuration/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 21 additions & 2 deletions backend/capellacollab/tools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand All @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions backend/capellacollab/users/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
40 changes: 40 additions & 0 deletions backend/capellacollab/users/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
5 changes: 5 additions & 0 deletions backend/capellacollab/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
36 changes: 31 additions & 5 deletions backend/capellacollab/users/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +41,18 @@
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)

Check warning on line 49 in backend/capellacollab/users/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L49

Added line #L49 was not covered by tests

return config_models.BetaConfiguration.model_validate(

Check warning on line 51 in backend/capellacollab/users/routes.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L51

Added line #L51 was not covered by tests
cfg.beta.model_dump()
)


@router.get(
"/{user_id}",
response_model=models.User,
Expand Down Expand Up @@ -125,18 +139,30 @@
@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,
user: models.DatabaseUser = fastapi.Depends(injectables.get_existing_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:
Expand Down
Loading
Loading