-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
9715d90
commit d13bed2
Showing
65 changed files
with
1,881 additions
and
104 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") | ||
|
@@ -342,3 +366,4 @@ class AppConfig(BaseConfig): | |
logging: LoggingConfig = LoggingConfig() | ||
requests: RequestsConfig = RequestsConfig() | ||
pipelines: PipelineConfig = PipelineConfig() | ||
smtp: t.Optional[SmtpConfig] = None |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
# 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" | ||
) | ||
trigger: str = pydantic.Field( | ||
description="What triggered the feedback form" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors | ||
# SPDX-License-Identifier: Apache-2.0 | ||
|
||
|
||
import typing as t | ||
|
||
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_agent: t.Annotated[str | None, fastapi.Header()] = None, | ||
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, user_agent, db) | ||
return {"status": "sending"} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
# 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], | ||
user_agent: str | None, | ||
): | ||
message = "\n".join( | ||
[ | ||
f"Rating: {feedback.rating.value}", | ||
f"Text: {feedback.feedback_text or 'No feedback text provided'}", | ||
f"User: {f'{user.name} ({user.email})' if user else 'Anonymous User'}", | ||
f"User Agent: {user_agent or 'Unknown'}", | ||
f"Feedback Trigger: {feedback.trigger}", | ||
*[ | ||
session.model_dump_json(indent=2) | ||
for session in feedback.sessions | ||
], | ||
] | ||
) | ||
|
||
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": message, | ||
} | ||
else: | ||
return { | ||
"subject": f"New General Feedback {feedback.rating.value.capitalize()}", | ||
"message": message, | ||
} | ||
|
||
|
||
def send_email( | ||
feedback: Feedback, | ||
user: users_models.DatabaseUser, | ||
user_agent: str | None, | ||
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 | ||
|
||
email_text = format_email( | ||
feedback, None if is_anonymous else user, user_agent | ||
) | ||
|
||
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) | ||
|
||
for receiver in feedback_config.receivers: | ||
msg = MIMEMultipart() | ||
msg["From"] = config.smtp.sender | ||
msg["To"] = receiver | ||
msg["Subject"] = email_text["subject"] | ||
msg.attach(MIMEText(email_text["message"], "plain")) | ||
|
||
mailserver.sendmail(config.smtp.sender, receiver, msg.as_string()) | ||
|
||
mailserver.quit() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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.", | ||
) | ||
receivers: list[pydantic.EmailStr] = pydantic.Field( | ||
default=[], | ||
description="Email addresses 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 | ||
|
@@ -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]] = { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.