Skip to content

Commit

Permalink
feat: Allow users to sign up as beta-testers
Browse files Browse the repository at this point in the history
  • Loading branch information
zusorio committed Nov 5, 2024
1 parent 76136c0 commit f3404e6
Show file tree
Hide file tree
Showing 37 changed files with 453 additions and 21 deletions.
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 @@ -207,7 +207,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(
default=f"{registry}/capella/remote:{docker_tag}",
beta=None,
),
),
),
backups=tools_models.ToolBackupConfiguration(
Expand Down Expand Up @@ -245,8 +248,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(
default=f"{config.docker.sessions_registry}/capella/remote:{papyrus_version_name}-latest",
beta=None,
),
),
),
Expand Down Expand Up @@ -327,8 +331,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(
default=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
1 change: 1 addition & 0 deletions backend/capellacollab/feedback/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def format_email(
f"Rating: {rating.capitalize()}",
f"Text: {feedback.feedback_text or 'No feedback text provided'}",
f"User: {user_msg}",
f"Beta Tester: {user.beta_tester if user else 'Unknown'}",
f"User Agent: {user_agent or 'Unknown'}",
]
if 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
10 changes: 8 additions & 2 deletions backend/capellacollab/sessions/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,16 @@ 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
template = (
version.config.sessions.persistent.image.beta
if beta and version.config.sessions.persistent.image.beta
else version.config.sessions.persistent.image.default
)

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=False,
description="Enable beta-testing features. Disabling this will un-enroll all beta-testers.",
)
allow_self_registration: bool = pydantic.Field(
default=False,
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
24 changes: 22 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):
default: str | None = pydantic.Field(
default="docker.io/hello-world:latest",
pattern=DOCKER_IMAGE_PATTERN,
examples=[
Expand All @@ -374,6 +374,26 @@ 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. "
"If set to None, persistent session support will be disabled for this tool version. "
"You can use '{version}' in the image, which will be replaced with the version name of the tool. "
"Always use tags to prevent breaking updates. "
),
)


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()
30 changes: 30 additions & 0 deletions backend/capellacollab/users/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,33 @@ def __init__(self):
reason=("You must provide a reason for updating the users roles."),
err_code="ROLE_UPDATE_REQUIRES_REASON",
)


class ChangesNotAllowedForRoleError(core_exceptions.BaseError):
def __init__(self):
super().__init__(

Check warning on line 45 in backend/capellacollab/users/exceptions.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/exceptions.py#L45

Added line #L45 was not covered by tests
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__(

Check warning on line 55 in backend/capellacollab/users/exceptions.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/exceptions.py#L55

Added line #L55 was not covered by tests
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__(

Check warning on line 65 in backend/capellacollab/users/exceptions.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/exceptions.py#L65

Added line #L65 was not covered by tests
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)
21 changes: 16 additions & 5 deletions backend/capellacollab/users/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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.users import injectables as users_injectables
from capellacollab.users import models as users_models
from capellacollab.users.tokens import routes as tokens_routes
Expand Down Expand Up @@ -125,18 +126,28 @@ 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,
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 any(patch_user.model_dump(exclude={"beta_tester"}).values()):
raise exceptions.ChangesNotAllowedForRoleError()

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

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L139

Added line #L139 was not covered by tests

if patch_user.beta_tester:
cfg = config_core.get_global_configuration(db)

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

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L142

Added line #L142 was not covered by tests
if not cfg.beta.enabled:
raise exceptions.BetaTestingDisabledError()

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

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L144

Added line #L144 was not covered by tests
if (
not cfg.beta.allow_self_registration
and own_user.role != models.Role.ADMIN
):
raise exceptions.BetaTestingSelfEnrollmentNotAllowedError()

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

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/users/routes.py#L149

Added line #L149 was not covered by tests

if patch_user.role and patch_user.role != user.role:
reason = patch_user.reason
if not reason:
Expand Down
16 changes: 12 additions & 4 deletions backend/tests/tools/versions/test_tools_version_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
Expand Down Expand Up @@ -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"},
},
Expand Down
Loading

0 comments on commit f3404e6

Please sign in to comment.