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 authored and MoritzWeber0 committed Nov 11, 2024
1 parent aefb522 commit b48ca51
Show file tree
Hide file tree
Showing 42 changed files with 734 additions and 23 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 @@ -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}/capella/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
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.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=False,
description="Enable beta-testing features. Disabling this will un-enroll all beta-testers.",
)
allow_self_enrollment: 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):
regular: 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()
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 ChangesNotForOtherUsersError(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 @@ 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)

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 @@ 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 own_user.id != user.id:
raise exceptions.ChangesNotForOtherUsersError()
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

0 comments on commit b48ca51

Please sign in to comment.