Skip to content

Commit

Permalink
feat: Add a feedback form
Browse files Browse the repository at this point in the history
Add a feedback form that can be triggered after terminating a session, on a session card, in regular
intervals, and in the footer. Feedback can optionally contain freeform text and include the users
contact information. Feedback includes an anonymized version of any associated sessions.

Closes #1742
  • Loading branch information
zusorio committed Sep 11, 2024
1 parent f43efb9 commit 516d96a
Show file tree
Hide file tree
Showing 65 changed files with 1,827 additions and 104 deletions.
25 changes: 25 additions & 0 deletions backend/capellacollab/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,30 @@ class PrometheusConfig(BaseConfig):
)


class SmtpConfig(BaseConfig):
enabled: bool = pydantic.Field(
default=False,
description="Whether to enable SMTP. Necessary for feedback.",
examples=[True, False],
)
host: str = pydantic.Field(
description="The SMTP server host.",
examples=["smtp.example.com:587"],
pattern=r"^(.*):(\d+)$",
)
user: str = pydantic.Field(
description="The SMTP server user.", examples=["username"]
)
password: str = pydantic.Field(
description="The SMTP server password.",
examples=["password"],
)
sender: str = pydantic.Field(
description="The sender email address.",
examples=["[email protected]"],
)


class AppConfig(BaseConfig):
docker: DockerConfig = DockerConfig()
k8s: K8sConfig = K8sConfig(context="k3d-collab-cluster")
Expand All @@ -342,3 +366,4 @@ class AppConfig(BaseConfig):
logging: LoggingConfig = LoggingConfig()
requests: RequestsConfig = RequestsConfig()
pipelines: PipelineConfig = PipelineConfig()
smtp: t.Optional[SmtpConfig] = None
2 changes: 2 additions & 0 deletions backend/capellacollab/feedback/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0
26 changes: 26 additions & 0 deletions backend/capellacollab/feedback/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

from fastapi import status

from capellacollab.core import exceptions as core_exceptions


class SmtpNotSetupError(core_exceptions.BaseError):
def __init__(self):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
title="SMTP is not set up",
reason="SMTP must be set up to perform this action",
err_code="SMTP_NOT_SETUP",
)


class FeedbackNotEnabledError(core_exceptions.BaseError):
def __init__(self):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
title="Feedback is not set enabled",
reason="Feedback must be set up to perform this action",
err_code="FEEDBACK_NOT_SETUP",
)
48 changes: 48 additions & 0 deletions backend/capellacollab/feedback/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import datetime
import enum
import typing as t

import pydantic

from capellacollab.core import models as core_models
from capellacollab.core import pydantic as core_pydantic
from capellacollab.sessions.models import SessionType
from capellacollab.tools import models as tools_models


class FeedbackRating(str, enum.Enum):
GOOD = "good"
OKAY = "okay"
BAD = "bad"


class AnonymizedSession(core_pydantic.BaseModel):
id: str
type: SessionType
created_at: datetime.datetime

version: tools_models.ToolVersionWithTool

state: str = pydantic.Field(default="UNKNOWN")
warnings: list[core_models.Message] = pydantic.Field(default=[])

connection_method_id: str
connection_method: tools_models.ToolSessionConnectionMethod | None = None


class Feedback(core_pydantic.BaseModel):
rating: FeedbackRating = pydantic.Field(
description="The rating of the feedback"
)
feedback_text: t.Optional[str] = pydantic.Field(
description="The feedback text"
)
share_contact: bool = pydantic.Field(
description="Whether the user wants to share their contact information"
)
sessions: list[AnonymizedSession] = pydantic.Field(
description="The sessions the feedback is for"
)
44 changes: 44 additions & 0 deletions backend/capellacollab/feedback/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0


import fastapi
from sqlalchemy import orm

from capellacollab.core import database
from capellacollab.feedback.models import Feedback
from capellacollab.feedback.util import is_feedback_allowed, send_email
from capellacollab.settings.configuration import core as config_core
from capellacollab.settings.configuration import (
models as settings_config_models,
)
from capellacollab.settings.configuration.models import FeedbackConfiguration
from capellacollab.users import injectables as user_injectables
from capellacollab.users import models as users_models

router = fastapi.APIRouter()


@router.get(
"/feedback",
response_model=FeedbackConfiguration,
)
def get_feedback(db: orm.Session = fastapi.Depends(database.get_db)):
cfg = config_core.get_config(db, "global")
assert isinstance(cfg, settings_config_models.GlobalConfiguration)

return FeedbackConfiguration.model_validate(cfg.feedback.model_dump())


@router.post("/feedback")
def submit_feedback(
feedback: Feedback,
background_tasks: fastapi.BackgroundTasks,
user: users_models.DatabaseUser = fastapi.Depends(
user_injectables.get_own_user
),
db: orm.Session = fastapi.Depends(database.get_db),
):
is_feedback_allowed(db)
background_tasks.add_task(send_email, feedback, user, db)
return {"status": "sending"}
103 changes: 103 additions & 0 deletions backend/capellacollab/feedback/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import smtplib
import typing as t
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

from sqlalchemy import orm

from capellacollab.config import config
from capellacollab.feedback import exceptions
from capellacollab.feedback.models import AnonymizedSession, Feedback
from capellacollab.settings.configuration import core as config_core
from capellacollab.settings.configuration import (
models as settings_config_models,
)
from capellacollab.settings.configuration.models import (
FeedbackAnonymityPolicy,
FeedbackConfiguration,
)
from capellacollab.users import models as users_models


def format_session(session: AnonymizedSession):
return f"{session.version.tool.name} ({session.version.name})"


def is_feedback_allowed(db: orm.Session):
if not config.smtp or not config.smtp.enabled:
raise exceptions.SmtpNotSetupError()

cfg = config_core.get_config(db, "global")
assert isinstance(cfg, settings_config_models.GlobalConfiguration)
feedback_config = FeedbackConfiguration.model_validate(
cfg.feedback.model_dump()
)
if not feedback_config.enabled:
raise exceptions.FeedbackNotEnabledError()


def format_email(
feedback: Feedback, user: t.Optional[users_models.DatabaseUser]
):
if len(feedback.sessions) > 0:
return {
"subject": f"New Feedback {feedback.rating.value.capitalize()} for {', '.join([format_session(session) for session in feedback.sessions])}",
"message": f"""Rating: {feedback.rating.value}
Text: {feedback.feedback_text or "No feedback text provided"}
User: {f'{user.name} ({user.email})' if user else "Anonymous User"}
{'\n'.join([session.model_dump_json(indent=2) for session in feedback.sessions])}""",
}
else:
return {
"subject": f"New General Feedback {feedback.rating.value.capitalize()}",
"message": f"""Rating: {feedback.rating.value}
Text: {feedback.feedback_text or "No feedback text provided"}
User: {f'{user.name} ({user.email})' if user else "Anonymous User"}""",
}


def send_email(
feedback: Feedback, user: users_models.DatabaseUser, db: orm.Session
):
is_feedback_allowed(db)
assert config.smtp, "SMTP configuration is not set up"

cfg = config_core.get_config(db, "global")
assert isinstance(cfg, settings_config_models.GlobalConfiguration)
feedback_config = FeedbackConfiguration.model_validate(
cfg.feedback.model_dump()
)

match feedback_config.anonymity_policy:
case FeedbackAnonymityPolicy.FORCE_ANONYMOUS:
is_anonymous = True
case FeedbackAnonymityPolicy.FORCE_IDENTIFIED:
is_anonymous = False
case _:
is_anonymous = not feedback

msg = MIMEMultipart()
msg["From"] = config.smtp.sender
msg["To"] = feedback_config.receiver
email_text = format_email(feedback, None if is_anonymous else user)

msg["Subject"] = email_text["subject"]
msg.attach(MIMEText(email_text["message"], "plain"))

mailserver = smtplib.SMTP(
config.smtp.host.split(":")[0], int(config.smtp.host.split(":")[1])
)
mailserver.ehlo()
mailserver.starttls()
mailserver.ehlo()
mailserver.login(config.smtp.user, config.smtp.password)

mailserver.sendmail(
config.smtp.sender, feedback_config.receiver, msg.as_string()
)

mailserver.quit()
2 changes: 2 additions & 0 deletions backend/capellacollab/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from capellacollab.core import responses as auth_responses
from capellacollab.core.authentication import routes as authentication_routes
from capellacollab.events import routes as events_router
from capellacollab.feedback import routes as feedback_routes
from capellacollab.health import routes as health_routes
from capellacollab.metadata import routes as core_metadata
from capellacollab.navbar import routes as navbar_routes
Expand All @@ -31,6 +32,7 @@
)
router.include_router(core_metadata.router, tags=["Metadata"])
router.include_router(navbar_routes.router, tags=["Navbar"])
router.include_router(feedback_routes.router, tags=["Feedback"])
router.include_router(
sessions_routes.router,
prefix="/sessions",
Expand Down
68 changes: 68 additions & 0 deletions backend/capellacollab/settings/configuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,70 @@ class NavbarConfiguration(core_pydantic.BaseModelStrict):
)


class FeedbackAnonymityPolicy(str, enum.Enum):
FORCE_ANONYMOUS = "force_anonymous"
FORCE_IDENTIFIED = "force_identified"
ASK_USER = "ask_user"


class FeedbackIntervalConfiguration(core_pydantic.BaseModelStrict):
enabled: bool = pydantic.Field(
default=True,
description="Whether the feedback interval is enabled.",
)
hours_between_prompt: int = pydantic.Field(
default=168,
description="The interval in hours between feedback requests.",
ge=0,
)


class FeedbackProbabilityConfiguration(core_pydantic.BaseModelStrict):
enabled: bool = pydantic.Field(
default=True,
description="Whether the feedback probability is enabled.",
)
percentage: int = pydantic.Field(
default=100,
description="The percentage of users that will be asked for feedback.",
ge=0,
le=100,
)


class FeedbackConfiguration(core_pydantic.BaseModelStrict):
enabled: bool = pydantic.Field(
default=False,
description="Enable or disable the feedback system. If enabled, SMTP configuration is required.",
)
after_session: FeedbackProbabilityConfiguration = pydantic.Field(
default_factory=FeedbackProbabilityConfiguration,
description="If a feedback form is shown after terminating a session.",
)
on_footer: bool = pydantic.Field(
default=True,
description="Should a general feedback button be shown.",
)
on_session_card: bool = pydantic.Field(
default=True,
description="Should a feedback button be shown on the session cards.",
)
interval: FeedbackIntervalConfiguration = pydantic.Field(
default_factory=FeedbackIntervalConfiguration,
description="Request feedback at regular intervals.",
)
receiver: pydantic.EmailStr = pydantic.Field(
default="[email protected]",
description="Email address to send feedback to.",
examples=["[email protected]"],
)
anonymity_policy: FeedbackAnonymityPolicy = pydantic.Field(
default=FeedbackAnonymityPolicy.ASK_USER,
description="If feedback should be anonymous or identified.",
examples=["force_anonymous", "force_identified", "ask_user"],
)


class ConfigurationBase(core_pydantic.BaseModelStrict, abc.ABC):
"""
Base class for configuration models. Can be used to define new configurations
Expand All @@ -111,6 +175,10 @@ class GlobalConfiguration(ConfigurationBase):
default_factory=NavbarConfiguration
)

feedback: FeedbackConfiguration = pydantic.Field(
default_factory=FeedbackConfiguration
)


# All subclasses of ConfigurationBase are automatically registered using this dict.
NAME_TO_MODEL_TYPE_MAPPING: dict[str, t.Type[ConfigurationBase]] = {
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies = [
"alembic==1.13.2",
"appdirs",
"cachetools",
"email-validator",
"fastapi>=0.112.4",
"kubernetes",
"psycopg2-binary>2.9.7",
Expand Down
Loading

0 comments on commit 516d96a

Please sign in to comment.