From 68829c1e8466e1bb724bb01c0744e22b74190820 Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Wed, 18 Sep 2024 15:46:32 +0200 Subject: [PATCH] refactor: Rewrite some parts of the feedback feature --- Makefile | 6 +- backend/capellacollab/config/models.py | 13 +- backend/capellacollab/core/__init__.py | 2 +- backend/capellacollab/core/email/__init__.py | 2 + .../capellacollab/core/email/exceptions.py | 16 ++ backend/capellacollab/core/email/models.py | 9 + backend/capellacollab/core/email/send.py | 51 ++++ backend/capellacollab/feedback/exceptions.py | 20 +- backend/capellacollab/feedback/models.py | 20 +- backend/capellacollab/feedback/routes.py | 48 ++-- backend/capellacollab/feedback/util.py | 161 +++++------ backend/capellacollab/metadata/routes.py | 6 +- backend/capellacollab/navbar/routes.py | 6 +- backend/capellacollab/sessions/routes.py | 3 +- .../settings/configuration/core.py | 6 + .../settings/configuration/models.py | 76 ++--- .../settings/configuration/routes.py | 7 +- backend/capellacollab/tools/models.py | 17 ++ backend/tests/projects/toolmodels/conftest.py | 4 +- .../tests/sessions/test_session_feedback.py | 155 ----------- .../settings/test_global_configuration.py | 80 +----- backend/tests/test_feedback.py | 259 ++++++++++++++++++ docs/docs/admin/configure-for-your-org.md | 18 +- frontend/src/app/app.component.ts | 4 +- .../app/general/footer/footer.component.html | 11 +- .../app/general/footer/footer.component.ts | 6 +- .../src/app/general/footer/footer.stories.ts | 27 +- .../metadata/version/version.component.html | 2 +- .../metadata/version/version.component.ts | 5 + .../app/general/nav-bar/nav-bar.service.ts | 1 + .../src/app/openapi/.openapi-generator/FILES | 18 +- .../src/app/openapi/api/feedback.service.ts | 12 +- frontend/src/app/openapi/api/tools.service.ts | 46 ++-- .../app/openapi/model/anonymized-session.ts | 9 +- .../app/openapi/model/built-in-link-item.ts | 5 +- .../model/feedback-anonymity-policy.ts | 21 -- .../model/feedback-configuration-input.ts | 13 +- .../model/feedback-configuration-output.ts | 13 +- frontend/src/app/openapi/model/feedback.ts | 5 +- ...minimal-tool-session-connection-method.ts} | 8 +- ...t.ts => minimal-tool-version-with-tool.ts} | 14 +- ...configuration-input.ts => minimal-tool.ts} | 12 +- frontend/src/app/openapi/model/models.ts | 18 +- ...ssion-ports-output.ts => session-ports.ts} | 2 +- frontend/src/app/openapi/model/session.ts | 8 +- frontend/src/app/openapi/model/tool-input.ts | 25 -- frontend/src/app/openapi/model/tool-model.ts | 4 +- .../tool-session-connection-method-input.ts | 29 -- ...t.ts => tool-session-connection-method.ts} | 6 +- .../model/tool-version-with-tool-input.ts | 25 -- ...ol-output.ts => tool-version-with-tool.ts} | 6 +- .../openapi/model/{tool-output.ts => tool.ts} | 2 +- .../models/init-model/init-model.component.ts | 4 +- .../feedback-dialog.component.html | 118 ++++---- .../feedback-dialog.component.ts | 36 ++- .../feedback-dialog.stories.ts | 62 ++++- .../app/sessions/feedback/feedback.service.ts | 55 +--- .../active-sessions.component.html | 2 +- .../active-sessions.component.ts | 4 +- .../active-sessions.stories.ts | 26 ++ .../create-readonly-session.component.ts | 10 +- .../create-persistent-session.component.ts | 6 +- ...create-readonly-model-options.component.ts | 4 +- ...reate-readonly-session-dialog.component.ts | 4 +- .../create-readonly-session-dialog.stories.ts | 4 +- .../create-session-history.component.ts | 6 +- .../configuration-settings.component.ts | 4 +- .../tool-deletion-dialog.component.ts | 4 +- .../tool-details/tool-details.component.ts | 6 +- .../tool-nature/tool-nature.component.ts | 6 +- .../tool-version/tool-version.component.ts | 6 +- .../core/tools-settings/tool.service.ts | 18 +- frontend/src/environments/environment.dev.ts | 1 + .../src/environments/environment.storybook.ts | 1 + frontend/src/environments/environment.ts | 1 + frontend/src/storybook/auth.ts | 13 + frontend/src/storybook/feedback.ts | 33 +++ frontend/src/storybook/tool.ts | 4 +- helm/config/backend.yaml | 9 +- .../templates/backend/backend.deployment.yaml | 1 + .../backend/postgres.deployment.yaml | 3 +- helm/templates/docs/docs.deployment.yaml | 1 + .../frontend/frontend.deployment.yaml | 1 + helm/templates/grafana/grafana.configmap.yaml | 13 +- .../templates/grafana/grafana.deployment.yaml | 1 + helm/templates/grafana/nginx.deployment.yaml | 1 + .../guacamole/guacamole.deployment.yaml | 1 + .../templates/guacamole/guacd.deployment.yaml | 1 + .../guacamole/postgres.deployment.yaml | 1 + helm/templates/mock/oauth.deployment.yaml | 1 + helm/templates/mock/smtp.deployment.yaml | 53 ++++ helm/templates/mock/smtp.service.yaml | 24 ++ .../prometheus/nginx.deployment.yaml | 1 + .../prometheus/prometheus.deployment.yaml | 1 + helm/templates/routing/routing.ingress.yaml | 9 + .../sessions/sessions.deployment.yaml | 1 + helm/templates/tpl/_containerSpec.tpl | 6 + helm/values.yaml | 9 +- mocks/smtp/Makefile | 10 + 99 files changed, 1097 insertions(+), 831 deletions(-) create mode 100644 backend/capellacollab/core/email/__init__.py create mode 100644 backend/capellacollab/core/email/exceptions.py create mode 100644 backend/capellacollab/core/email/models.py create mode 100644 backend/capellacollab/core/email/send.py delete mode 100644 backend/tests/sessions/test_session_feedback.py create mode 100644 backend/tests/test_feedback.py delete mode 100644 frontend/src/app/openapi/model/feedback-anonymity-policy.ts rename frontend/src/app/openapi/model/{session-ports-input.ts => minimal-tool-session-connection-method.ts} (74%) rename frontend/src/app/openapi/model/{feedback-probability-configuration-output.ts => minimal-tool-version-with-tool.ts} (59%) rename frontend/src/app/openapi/model/{feedback-probability-configuration-input.ts => minimal-tool.ts} (59%) rename frontend/src/app/openapi/model/{session-ports-output.ts => session-ports.ts} (92%) delete mode 100644 frontend/src/app/openapi/model/tool-input.ts delete mode 100644 frontend/src/app/openapi/model/tool-session-connection-method-input.ts rename frontend/src/app/openapi/model/{tool-session-connection-method-output.ts => tool-session-connection-method.ts} (85%) delete mode 100644 frontend/src/app/openapi/model/tool-version-with-tool-input.ts rename frontend/src/app/openapi/model/{tool-version-with-tool-output.ts => tool-version-with-tool.ts} (84%) rename frontend/src/app/openapi/model/{tool-output.ts => tool.ts} (95%) create mode 100644 frontend/src/storybook/auth.ts create mode 100644 frontend/src/storybook/feedback.ts create mode 100644 helm/templates/mock/smtp.deployment.yaml create mode 100644 helm/templates/mock/smtp.service.yaml create mode 100644 helm/templates/tpl/_containerSpec.tpl create mode 100644 mocks/smtp/Makefile diff --git a/Makefile b/Makefile index b84bee3729..1f3c17715f 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,7 @@ helm-deploy: --set docker.registry.sessions=$(CAPELLACOLLAB_SESSIONS_REGISTRY) \ --set docker.tag=$(DOCKER_TAG) \ --set mocks.oauth=True \ + --set mocks.smtp=True \ --set backend.authentication.claimMapping.username=sub \ --set backend.authentication.endpoints.authorization=https://localhost/default/authorize \ --set development=$(DEVELOPMENT_MODE) \ @@ -191,7 +192,7 @@ reach-registry: fi dev: - $(MAKE) -j5 dev-frontend dev-backend dev-oauth-mock dev-docs dev-storybook + $(MAKE) -j6 dev-frontend dev-backend dev-oauth-mock dev-smtp-mock dev-docs dev-storybook dev-frontend: $(MAKE) -C frontend dev @@ -202,6 +203,9 @@ dev-backend: dev-oauth-mock: $(MAKE) -C mocks/oauth start +dev-smtp-mock: + $(MAKE) -C mocks/smtp start + dev-docs: $(MAKE) -C docs serve diff --git a/backend/capellacollab/config/models.py b/backend/capellacollab/config/models.py index 9315d39178..b550e4fcd0 100644 --- a/backend/capellacollab/config/models.py +++ b/backend/capellacollab/config/models.py @@ -330,25 +330,30 @@ class PrometheusConfig(BaseConfig): ) -class SmtpConfig(BaseConfig): +class SMTPConfig(BaseConfig): enabled: bool = pydantic.Field( - default=False, + default=True, description="Whether to enable SMTP. Necessary for feedback.", examples=[True, False], ) host: str = pydantic.Field( description="The SMTP server host.", + default="localhost:587", examples=["smtp.example.com:587"], pattern=r"^(.*):(\d+)$", ) user: str = pydantic.Field( - description="The SMTP server user.", examples=["username"] + default="username", + description="The SMTP server user.", + examples=["username"], ) password: str = pydantic.Field( + default="password", description="The SMTP server password.", examples=["password"], ) sender: str = pydantic.Field( + default="capella@example.com", description="The sender email address.", examples=["capella@example.com"], ) @@ -366,4 +371,4 @@ class AppConfig(BaseConfig): logging: LoggingConfig = LoggingConfig() requests: RequestsConfig = RequestsConfig() pipelines: PipelineConfig = PipelineConfig() - smtp: t.Optional[SmtpConfig] = None + smtp: SMTPConfig | None = SMTPConfig() diff --git a/backend/capellacollab/core/__init__.py b/backend/capellacollab/core/__init__.py index 643d920477..33a97c0118 100644 --- a/backend/capellacollab/core/__init__.py +++ b/backend/capellacollab/core/__init__.py @@ -3,7 +3,7 @@ import os -DEVELOPMENT_MODE = os.getenv("DEVELOPMENT_MODE", "").lower() in ( +DEVELOPMENT_MODE: bool = os.getenv("DEVELOPMENT_MODE", "").lower() in ( "1", "true", "t", diff --git a/backend/capellacollab/core/email/__init__.py b/backend/capellacollab/core/email/__init__.py new file mode 100644 index 0000000000..04412280d8 --- /dev/null +++ b/backend/capellacollab/core/email/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/core/email/exceptions.py b/backend/capellacollab/core/email/exceptions.py new file mode 100644 index 0000000000..a3003990df --- /dev/null +++ b/backend/capellacollab/core/email/exceptions.py @@ -0,0 +1,16 @@ +# 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 SMTPNotConfiguredError(core_exceptions.BaseError): + def __init__(self): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="SMTP is not configured", + reason="SMTP must be configured in the application configuration before sending emails and activating related features.", + err_code="SMTP_NOT_CONFIGURED", + ) diff --git a/backend/capellacollab/core/email/models.py b/backend/capellacollab/core/email/models.py new file mode 100644 index 0000000000..588dc2268c --- /dev/null +++ b/backend/capellacollab/core/email/models.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from capellacollab.core import pydantic as core_pydantic + + +class EMailContent(core_pydantic.BaseModelStrict): + subject: str + message: str diff --git a/backend/capellacollab/core/email/send.py b/backend/capellacollab/core/email/send.py new file mode 100644 index 0000000000..304839fc87 --- /dev/null +++ b/backend/capellacollab/core/email/send.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import smtplib +from email.mime import multipart, text + +import pydantic + +from capellacollab.config import config + +from . import exceptions, models + + +def send_email( + recipients: list[pydantic.EmailStr], + email: models.EMailContent, + logger: logging.LoggerAdapter, +): + if not (config.smtp and config.smtp.enabled): + raise exceptions.SMTPNotConfiguredError() + + try: + with smtplib.SMTP( + config.smtp.host.split(":")[0], int(config.smtp.host.split(":")[1]) + ) as smtp: + smtp.ehlo() + smtp.starttls() + smtp.ehlo() + smtp.login(config.smtp.user, config.smtp.password) + + logger.info( + "Sending emails to recipients %s", ", ".join(recipients) + ) + + for recipient in recipients: + msg = multipart.MIMEMultipart() + msg["From"] = config.smtp.sender + msg["To"] = recipient + msg["Subject"] = email.subject + msg.attach(text.MIMEText(email.message, "plain")) + + logger.info( + "Sending email to '%s' with subject '%s'", + recipient, + email.subject, + ) + + smtp.sendmail(config.smtp.sender, recipient, msg.as_string()) + except Exception: + logger.exception("Error while sending email(s).") diff --git a/backend/capellacollab/feedback/exceptions.py b/backend/capellacollab/feedback/exceptions.py index 41d380c84b..5bcc47a4c0 100644 --- a/backend/capellacollab/feedback/exceptions.py +++ b/backend/capellacollab/feedback/exceptions.py @@ -6,21 +6,21 @@ from capellacollab.core import exceptions as core_exceptions -class SmtpNotSetupError(core_exceptions.BaseError): +class FeedbackNotEnabledError(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", + status_code=status.HTTP_403_FORBIDDEN, + title="Feedback is not enabled", + reason="Feedback must be set up to perform this action", + err_code="FEEDBACK_NOT_ENABLED", ) -class FeedbackNotEnabledError(core_exceptions.BaseError): +class NoFeedbackRecipientsError(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", + status_code=status.HTTP_400_BAD_REQUEST, + title="The list of recipients is empty", + reason="Feedback can only be activated when there are recipients.", + err_code="FEEDBACK_MISSING_RECIPIENTS", ) diff --git a/backend/capellacollab/feedback/models.py b/backend/capellacollab/feedback/models.py index c32e85a1ac..fc4b234f31 100644 --- a/backend/capellacollab/feedback/models.py +++ b/backend/capellacollab/feedback/models.py @@ -3,13 +3,12 @@ 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.sessions import models as sessions_models from capellacollab.tools import models as tools_models @@ -21,24 +20,25 @@ class FeedbackRating(str, enum.Enum): class AnonymizedSession(core_pydantic.BaseModel): id: str - type: SessionType + type: sessions_models.SessionType created_at: datetime.datetime - version: tools_models.ToolVersionWithTool + version: tools_models.MinimalToolVersionWithTool 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 + connection_method: ( + tools_models.MinimalToolSessionConnectionMethod | 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" + feedback_text: str | None = pydantic.Field( + description="The feedback text", max_length=255 ) share_contact: bool = pydantic.Field( description="Whether the user wants to share their contact information" @@ -46,6 +46,6 @@ class Feedback(core_pydantic.BaseModel): sessions: list[AnonymizedSession] = pydantic.Field( description="The sessions the feedback is for" ) - trigger: str = pydantic.Field( - description="What triggered the feedback form" + trigger: str | None = pydantic.Field( + description="What triggered the feedback form", max_length=255 ) diff --git a/backend/capellacollab/feedback/routes.py b/backend/capellacollab/feedback/routes.py index 117af88cbd..69a3b54cff 100644 --- a/backend/capellacollab/feedback/routes.py +++ b/backend/capellacollab/feedback/routes.py @@ -2,46 +2,62 @@ # SPDX-License-Identifier: Apache-2.0 +import logging import typing as t import fastapi from sqlalchemy import orm +from capellacollab.config import config from capellacollab.core import database -from capellacollab.feedback.models import Feedback -from capellacollab.feedback.util import is_feedback_allowed, send_email +from capellacollab.core import logging as log +from capellacollab.core.authentication import injectables as auth_injectables 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 +from . import models, util + router = fastapi.APIRouter() @router.get( - "/feedback", + "/configurations/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()) +def get_feedback_configuration( + db: orm.Session = fastapi.Depends(database.get_db), +): + feedback = config_core.get_global_configuration(db).feedback + if not (config.smtp and config.smtp.enabled): + util.disable_feedback(feedback) + return feedback -@router.post("/feedback") +@router.post( + "/feedback", + status_code=204, + dependencies=[ + fastapi.Depends( + auth_injectables.RoleVerification( + required_role=users_models.Role.USER + ) + ) + ], +) def submit_feedback( - feedback: Feedback, + feedback: models.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), + logger: logging.LoggerAdapter = fastapi.Depends(log.get_request_logger), ): - is_feedback_allowed(db) - background_tasks.add_task(send_email, feedback, user, user_agent, db) - return {"status": "sending"} + util.check_if_feedback_is_allowed(db) + + background_tasks.add_task( + util.send_feedback_email, db, feedback, user, user_agent, logger + ) diff --git a/backend/capellacollab/feedback/util.py b/backend/capellacollab/feedback/util.py index f07c373805..32a846fa65 100644 --- a/backend/capellacollab/feedback/util.py +++ b/backend/capellacollab/feedback/util.py @@ -1,117 +1,118 @@ # 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 +import logging from sqlalchemy import orm from capellacollab.config import config -from capellacollab.feedback import exceptions -from capellacollab.feedback.models import AnonymizedSession, Feedback +from capellacollab.core.email import exceptions as email_exceptions +from capellacollab.core.email import models as email_models +from capellacollab.core.email import send as email_send 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 +from . import exceptions, models + + +def validate_global_configuration( + feedback: settings_config_models.FeedbackConfiguration, +): + if feedback.enabled and not (config.smtp and config.smtp.enabled): + raise email_exceptions.SMTPNotConfiguredError() + + if feedback.enabled and not feedback.recipients: + raise exceptions.NoFeedbackRecipientsError() + -def format_session(session: AnonymizedSession): +def disable_feedback(feedback: settings_config_models.FeedbackConfiguration): + feedback.enabled = False + feedback.after_session = False + feedback.on_footer = False + feedback.on_session_card = False + feedback.interval.enabled = False + + +def format_session(session: models.AnonymizedSession): return f"{session.version.tool.name} ({session.version.name})" -def is_feedback_allowed(db: orm.Session): +def check_if_feedback_is_allowed(db: orm.Session): if not config.smtp or not config.smtp.enabled: - raise exceptions.SmtpNotSetupError() + raise email_exceptions.SMTPNotConfiguredError() - 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: + cfg = config_core.get_global_configuration(db) + if not cfg.feedback.enabled: raise exceptions.FeedbackNotEnabledError() def format_email( - feedback: Feedback, - user: t.Optional[users_models.DatabaseUser], + feedback: models.Feedback, + user: users_models.DatabaseUser | None, 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 - ], - ] +) -> email_models.EMailContent: + rating = feedback.rating.value + user_msg = user.name if user else "Anonymous" + if user and user.email: + user_msg += f" ({user.email})" + + message_list = [ + f"Rating: {rating.capitalize()}", + f"Text: {feedback.feedback_text or 'No feedback text provided'}", + f"User: {user_msg}", + f"User Agent: {user_agent or 'Unknown'}", + ] + if feedback.trigger: + message_list.append(f"Trigger: {feedback.trigger}") + message_list.append("Sessions:") + message_list += [ + session.model_dump_json(indent=2) for session in feedback.sessions + ] + + message_list.append("---") + message_list.append( + f"You receive this email because you're registered as feedback recipient in the " + f"Capella Collaboration Manager ({config.general.scheme}://{config.general.host}:{config.general.port})." + ) + message_list.append( + "If you want to unsubscribe, contact your System Administrator." ) + message_list.append( + "Please note that only the user is validated. All other fields are provided via the API and should not be trusted." + ) + message = "\n".join(message_list) 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, - } + sessions = ", ".join( + [format_session(session) for session in feedback.sessions] + ) + return email_models.EMailContent( + subject=f"New Feedback with rating {rating} for sessions: {sessions}", + message=message, + ) else: - return { - "subject": f"New General Feedback {feedback.rating.value.capitalize()}", - "message": message, - } + return email_models.EMailContent( + subject=f"New General Feedback with rating {rating}", + message=message, + ) -def send_email( - feedback: Feedback, +def send_feedback_email( + db: orm.Session, + feedback: models.Feedback, user: users_models.DatabaseUser, user_agent: str | None, - db: orm.Session, + logger: logging.LoggerAdapter, ): - 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 + check_if_feedback_is_allowed(db) + assert config.smtp # Already checked in previous function + cfg = config_core.get_global_configuration(db) 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]) + feedback, user if feedback.share_contact else None, user_agent ) - 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() + email_send.send_email(cfg.feedback.recipients, email_text, logger) diff --git a/backend/capellacollab/metadata/routes.py b/backend/capellacollab/metadata/routes.py index 0d01ad3cd0..d079708618 100644 --- a/backend/capellacollab/metadata/routes.py +++ b/backend/capellacollab/metadata/routes.py @@ -9,9 +9,6 @@ from capellacollab.core import database from capellacollab.core import pydantic as core_pydantic from capellacollab.settings.configuration import core as config_core -from capellacollab.settings.configuration import ( - models as settings_config_models, -) class Metadata(core_pydantic.BaseModel): @@ -35,8 +32,7 @@ class Metadata(core_pydantic.BaseModel): response_model=Metadata, ) def get_metadata(db: orm.Session = fastapi.Depends(database.get_db)): - cfg = config_core.get_config(db, "global") - assert isinstance(cfg, settings_config_models.GlobalConfiguration) + cfg = config_core.get_global_configuration(db) return Metadata.model_validate( cfg.metadata.model_dump() diff --git a/backend/capellacollab/navbar/routes.py b/backend/capellacollab/navbar/routes.py index f6849d9ad1..051783e4ab 100644 --- a/backend/capellacollab/navbar/routes.py +++ b/backend/capellacollab/navbar/routes.py @@ -6,9 +6,6 @@ from capellacollab.core import database 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 NavbarConfiguration router = fastapi.APIRouter() @@ -19,7 +16,6 @@ response_model=NavbarConfiguration, ) def get_navbar(db: orm.Session = fastapi.Depends(database.get_db)): - cfg = config_core.get_config(db, "global") - assert isinstance(cfg, settings_config_models.GlobalConfiguration) + cfg = config_core.get_global_configuration(db) return NavbarConfiguration.model_validate(cfg.navbar.model_dump()) diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index bd40326475..ca60913833 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -37,7 +37,8 @@ required_role=users_models.Role.USER ) ) - ] + ], + responses=responses.api_exceptions(include_authentication=True), ) router_without_authentication = fastapi.APIRouter() diff --git a/backend/capellacollab/settings/configuration/core.py b/backend/capellacollab/settings/configuration/core.py index f84f310d98..2bd85eb86f 100644 --- a/backend/capellacollab/settings/configuration/core.py +++ b/backend/capellacollab/settings/configuration/core.py @@ -13,3 +13,9 @@ def get_config(db: orm.Session, name: str) -> models.ConfigurationBase: if configuration: return model_type().model_validate(configuration.configuration) return model_type().model_validate({}) + + +def get_global_configuration(db: orm.Session) -> models.GlobalConfiguration: + cfg = get_config(db, "global") + assert isinstance(cfg, models.GlobalConfiguration) + return cfg diff --git a/backend/capellacollab/settings/configuration/models.py b/backend/capellacollab/settings/configuration/models.py index 6156231c85..a3b8d79f1e 100644 --- a/backend/capellacollab/settings/configuration/models.py +++ b/backend/capellacollab/settings/configuration/models.py @@ -8,6 +8,7 @@ import pydantic from sqlalchemy import orm +from capellacollab import core from capellacollab.core import database from capellacollab.core import pydantic as core_pydantic from capellacollab.users import models as users_models @@ -43,6 +44,7 @@ class BuiltInLinkItem(str, enum.Enum): GRAFANA = "grafana" PROMETHEUS = "prometheus" DOCUMENTATION = "documentation" + SMTP_MOCK = "smtp_mock" class NavbarLink(core_pydantic.BaseModelStrict): @@ -67,37 +69,44 @@ class CustomNavbarLink(NavbarLink): class NavbarConfiguration(core_pydantic.BaseModelStrict): external_links: list[BuiltInNavbarLink | CustomNavbarLink] = ( pydantic.Field( - default=[ - BuiltInNavbarLink( - name="Grafana", - service=BuiltInLinkItem.GRAFANA, - role=users_models.Role.ADMIN, - ), - BuiltInNavbarLink( - name="Prometheus", - service=BuiltInLinkItem.PROMETHEUS, - role=users_models.Role.ADMIN, - ), - BuiltInNavbarLink( - name="Documentation", - service=BuiltInLinkItem.DOCUMENTATION, - role=users_models.Role.USER, - ), - ], + default=( + [ + BuiltInNavbarLink( + name="Grafana", + service=BuiltInLinkItem.GRAFANA, + role=users_models.Role.ADMIN, + ), + BuiltInNavbarLink( + name="Prometheus", + service=BuiltInLinkItem.PROMETHEUS, + role=users_models.Role.ADMIN, + ), + BuiltInNavbarLink( + name="Documentation", + service=BuiltInLinkItem.DOCUMENTATION, + role=users_models.Role.USER, + ), + ] + + ( + [ + BuiltInNavbarLink( + name="SMTP Mock", + service=BuiltInLinkItem.SMTP_MOCK, + role=users_models.Role.USER, + ) + ] + if core.DEVELOPMENT_MODE + else [] + ) + ), description="Links to display in the navigation bar.", ) ) -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, + default=core.DEVELOPMENT_MODE, description="Whether the feedback interval is enabled.", ) hours_between_prompt: int = pydantic.Field( @@ -122,35 +131,30 @@ class FeedbackProbabilityConfiguration(core_pydantic.BaseModelStrict): class FeedbackConfiguration(core_pydantic.BaseModelStrict): enabled: bool = pydantic.Field( - default=False, + default=core.DEVELOPMENT_MODE, description="Enable or disable the feedback system. If enabled, SMTP configuration is required.", ) - after_session: FeedbackProbabilityConfiguration = pydantic.Field( - default_factory=FeedbackProbabilityConfiguration, + after_session: bool = pydantic.Field( + default=core.DEVELOPMENT_MODE, description="If a feedback form is shown after terminating a session.", ) on_footer: bool = pydantic.Field( - default=True, + default=core.DEVELOPMENT_MODE, description="Should a general feedback button be shown.", ) on_session_card: bool = pydantic.Field( - default=True, + default=core.DEVELOPMENT_MODE, 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=[], + recipients: list[pydantic.EmailStr] = pydantic.Field( + default=["test@example.com"] if core.DEVELOPMENT_MODE else [], description="Email addresses to send feedback to.", examples=[[], ["test@example.com"]], ) - 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): diff --git a/backend/capellacollab/settings/configuration/routes.py b/backend/capellacollab/settings/configuration/routes.py index d7dba47367..6f0bc593a7 100644 --- a/backend/capellacollab/settings/configuration/routes.py +++ b/backend/capellacollab/settings/configuration/routes.py @@ -8,6 +8,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 models as users_models from . import core, crud, models @@ -33,7 +34,7 @@ async def get_configuration( db: orm.Session = fastapi.Depends(database.get_db), ): - return core.get_config(db, models.GlobalConfiguration._name) + return core.get_global_configuration(db) @router.put( @@ -48,6 +49,10 @@ async def update_configuration( db, models.GlobalConfiguration._name ) + feedback_util.validate_global_configuration(body.feedback) + if body.feedback.enabled is False: + feedback_util.disable_feedback(body.feedback) + if configuration: return crud.update_configuration( db, configuration, body.model_dump() diff --git a/backend/capellacollab/tools/models.py b/backend/capellacollab/tools/models.py index 20f041d96c..2d6a10f0a2 100644 --- a/backend/capellacollab/tools/models.py +++ b/backend/capellacollab/tools/models.py @@ -84,6 +84,11 @@ class ToolSessionSharingConfiguration(core_pydantic.BaseModel): ) +class MinimalToolSessionConnectionMethod(core_pydantic.BaseModel): + id: str + name: str + + class ToolSessionConnectionMethod(core_pydantic.BaseModel): id: str = pydantic.Field(default_factory=uuid_factory) type: str @@ -516,6 +521,18 @@ class ToolVersionWithTool(ToolVersion): tool: Tool +class MinimalTool(core_pydantic.BaseModel): + id: int = pydantic.Field(ge=1) + name: str = pydantic.Field(default="", min_length=2, max_length=30) + + +class MinimalToolVersionWithTool(core_pydantic.BaseModel): + id: int = pydantic.Field(ge=1) + name: str = pydantic.Field(default="", min_length=2, max_length=30) + + tool: MinimalTool + + class ToolNature(core_pydantic.BaseModel): id: int name: str diff --git a/backend/tests/projects/toolmodels/conftest.py b/backend/tests/projects/toolmodels/conftest.py index 9fec519b1a..ea35572c96 100644 --- a/backend/tests/projects/toolmodels/conftest.py +++ b/backend/tests/projects/toolmodels/conftest.py @@ -143,7 +143,7 @@ def fixture_mock_git_rest_api_for_artifacts( @pytest.fixture(name="git_query_params") -def fixture_git_query_params(request: pytest.FixtureRequest) -> t.List[dict]: +def fixture_git_query_params(request: pytest.FixtureRequest) -> list[dict]: return request.param @@ -174,7 +174,7 @@ def fixture_mock_git_get_commit_information_api( request: pytest.FixtureRequest, git_type: git_models.GitType, git_response_status: int, - git_query_params: t.List[dict], + git_query_params: list[dict], ): match git_type: case git_models.GitType.GITLAB: diff --git a/backend/tests/sessions/test_session_feedback.py b/backend/tests/sessions/test_session_feedback.py deleted file mode 100644 index eb07918ddc..0000000000 --- a/backend/tests/sessions/test_session_feedback.py +++ /dev/null @@ -1,155 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -from unittest import mock - -import pytest -from fastapi import testclient -from sqlalchemy import orm - -import capellacollab.feedback.util -from capellacollab.config import models as config_models -from capellacollab.settings.configuration import crud as configuration_crud - - -@pytest.fixture(name="smtp_config_set") -def mock_smtp_config_set(monkeypatch: pytest.MonkeyPatch): - mocked_config = config_models.AppConfig( - smtp=config_models.SmtpConfig( - enabled=True, - host="smtp.example.com:587", - user="smtp_user", - password="smtp_password", - sender="capella@example.com", - ) - ) - monkeypatch.setattr(capellacollab.feedback.util, "config", mocked_config) - - -@pytest.fixture(name="smtp_config_not_set") -def mock_smtp_config_not_set(monkeypatch: pytest.MonkeyPatch): - mocked_config = config_models.AppConfig(smtp=None) - monkeypatch.setattr(capellacollab.feedback.util, "config", mocked_config) - - -@pytest.fixture(name="feedback_enabled") -def mock_feedback_enabled(db: orm.Session): - configuration_crud.create_configuration( - db, "global", {"feedback": {"enabled": True}} - ) - - -@pytest.fixture(name="feedback_disabled") -def mock_feedback_disabled(db: orm.Session): - configuration_crud.create_configuration( - db, "global", {"feedback": {"enabled": False}} - ) - - -@pytest.mark.usefixtures("user") -@pytest.mark.usefixtures("smtp_config_set") -@pytest.mark.usefixtures("feedback_enabled") -def test_send_feedback( - client: testclient.TestClient, -): - with mock.patch( - "capellacollab.feedback.routes.send_email" - ) as send_email_mock: - response = client.post( - "/api/v1/feedback", - json={ - "rating": "good", - "share_contact": False, - "sessions": [], - "feedback_text": None, - "trigger": "test", - }, - ) - - assert response.status_code == 200 - assert response.json() == {"status": "sending"} - - send_email_mock.assert_called_once() - - -@pytest.mark.usefixtures("user") -@pytest.mark.usefixtures("smtp_config_set") -@pytest.mark.usefixtures("feedback_enabled") -def test_send_feedback_with_contact( - client: testclient.TestClient, -): - with mock.patch( - "capellacollab.feedback.routes.send_email" - ) as send_email_mock: - response = client.post( - "/api/v1/feedback", - json={ - "rating": "good", - "share_contact": True, - "sessions": [], - "feedback_text": None, - "trigger": "test", - }, - ) - - assert response.status_code == 200 - assert response.json() == {"status": "sending"} - - send_email_mock.assert_called_once() - - -@pytest.mark.usefixtures("user") -@pytest.mark.usefixtures("smtp_config_set") -@pytest.mark.usefixtures("feedback_disabled") -def test_send_feedback_fail_disabled( - client: testclient.TestClient, -): - response = client.post( - "/api/v1/feedback", - json={ - "rating": "good", - "share_contact": False, - "sessions": [], - "feedback_text": None, - "trigger": "test", - }, - ) - assert response.status_code == 500 - - -@pytest.mark.usefixtures("user") -@pytest.mark.usefixtures("smtp_config_not_set") -@pytest.mark.usefixtures("feedback_enabled") -def test_send_feedback_fail_missing_smtp( - client: testclient.TestClient, -): - response = client.post( - "/api/v1/feedback", - json={ - "rating": "good", - "share_contact": False, - "sessions": [], - "feedback_text": None, - "trigger": "test", - }, - ) - assert response.status_code == 500 - - -@pytest.mark.usefixtures("user") -@pytest.mark.usefixtures("smtp_config_not_set") -@pytest.mark.usefixtures("feedback_disabled") -def test_send_feedback_fail_disabled_and_missing_smtp( - client: testclient.TestClient, -): - response = client.post( - "/api/v1/feedback", - json={ - "rating": "good", - "share_contact": False, - "sessions": [], - "feedback_text": None, - "trigger": "test", - }, - ) - assert response.status_code == 500 diff --git a/backend/tests/settings/test_global_configuration.py b/backend/tests/settings/test_global_configuration.py index 5868bc2680..ce3c1c7f1d 100644 --- a/backend/tests/settings/test_global_configuration.py +++ b/backend/tests/settings/test_global_configuration.py @@ -113,22 +113,10 @@ def test_update_general_configuration_additional_properties_fails( assert response.json()["detail"][0]["type"] == "extra_forbidden" +@pytest.mark.usefixtures("admin") def test_metadata_is_updated( client: testclient.TestClient, - db: orm.Session, - executor_name: str, ): - admin = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - - def get_mock_own_user(): - return admin - - app.dependency_overrides[users_injectables.get_own_user] = ( - get_mock_own_user - ) - response = client.put( "/api/v1/settings/configurations/global", json={ @@ -144,29 +132,15 @@ def get_mock_own_user(): assert response.status_code == 200 - del app.dependency_overrides[users_injectables.get_own_user] - response = client.get("/api/v1/metadata") assert response.status_code == 200 assert response.json()["environment"] == "test" +@pytest.mark.usefixtures("admin") def test_navbar_is_updated( client: testclient.TestClient, - db: orm.Session, - executor_name: str, ): - admin = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - - def get_mock_own_user(): - return admin - - app.dependency_overrides[users_injectables.get_own_user] = ( - get_mock_own_user - ) - response = client.put( "/api/v1/settings/configurations/global", json={ @@ -184,8 +158,6 @@ def get_mock_own_user(): assert response.status_code == 200 - del app.dependency_overrides[users_injectables.get_own_user] - response = client.get("/api/v1/navbar") assert response.status_code == 200 assert response.json()["external_links"][0] == { @@ -193,51 +165,3 @@ def get_mock_own_user(): "href": "https://example.com", "role": "user", } - - -def test_feedback_is_updated( - client: testclient.TestClient, - db: orm.Session, - executor_name: str, -): - admin = users_crud.create_user( - db, executor_name, executor_name, None, users_models.Role.ADMIN - ) - - def get_mock_own_user(): - return admin - - app.dependency_overrides[users_injectables.get_own_user] = ( - get_mock_own_user - ) - - response = client.put( - "/api/v1/settings/configurations/global", - json={ - "feedback": { - "enabled": True, - "after_session": {"enabled": True, "percentage": 100}, - "on_footer": True, - "on_session_card": True, - "interval": {"enabled": True, "hours_between_prompt": 24}, - "receivers": ["test@example.com"], - "anonymity_policy": "ask_user", - } - }, - ) - - assert response.status_code == 200 - - del app.dependency_overrides[users_injectables.get_own_user] - - response = client.get("/api/v1/feedback") - assert response.status_code == 200 - assert response.json() == { - "enabled": True, - "after_session": {"enabled": True, "percentage": 100}, - "on_footer": True, - "on_session_card": True, - "interval": {"enabled": True, "hours_between_prompt": 24}, - "receivers": ["test@example.com"], - "anonymity_policy": "ask_user", - } diff --git a/backend/tests/test_feedback.py b/backend/tests/test_feedback.py new file mode 100644 index 0000000000..46b4ee8262 --- /dev/null +++ b/backend/tests/test_feedback.py @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from unittest import mock + +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.config import config +from capellacollab.config import models as config_models +from capellacollab.settings.configuration import crud as configuration_crud + + +@pytest.fixture(name="smtp_config_set") +def fixture_smtp_config_set(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + config, + "smtp", + config_models.SMTPConfig( + enabled=True, + host="smtp.example.com:587", + user="smtp_user", + password="smtp_password", + sender="capella@example.com", + ), + ) + + +@pytest.fixture(name="smtp_config_not_set") +def fixture_smtp_config_not_set(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "smtp", None) + + +@pytest.fixture(name="feedback_enabled") +def fixture_feedback_enabled(db: orm.Session): + configuration_crud.create_configuration( + db, "global", {"feedback": {"enabled": True}} + ) + + +@pytest.fixture(name="feedback_disabled") +def fixture_feedback_disabled(db: orm.Session): + configuration_crud.create_configuration( + db, "global", {"feedback": {"enabled": False}} + ) + + +@pytest.mark.usefixtures("user", "smtp_config_set", "feedback_enabled") +def test_send_feedback( + client: testclient.TestClient, +): + with mock.patch( + "capellacollab.core.email.send.send_email" + ) as send_email_mock: + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": False, + "sessions": [], + "feedback_text": None, + "trigger": "test", + }, + ) + + assert response.status_code == 204 + send_email_mock.assert_called_once() + + +@pytest.mark.usefixtures("user", "smtp_config_set", "feedback_enabled") +def test_send_feedback_with_session( + client: testclient.TestClient, +): + with mock.patch( + "capellacollab.core.email.send.send_email" + ) as send_email_mock: + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": False, + "sessions": [ + { + "id": "gqakbljquqenfmzvflacgqntx", + "type": "persistent", + "created_at": "2024-09-19T21:02:12Z", + "version": { + "id": 4, + "name": "6.1.0", + "tool": {"id": 1, "name": "Capella"}, + }, + "state": "Started", + "warnings": [], + "connection_method": { + "id": "xpra", + "name": "Experimental (Xpra)", + }, + } + ], + "feedback_text": None, + "trigger": "test", + }, + ) + + assert response.status_code == 204 + send_email_mock.assert_called_once() + + +@pytest.mark.usefixtures("user", "smtp_config_set", "feedback_enabled") +def test_send_feedback_with_contact( + client: testclient.TestClient, +): + with mock.patch( + "capellacollab.core.email.send.send_email" + ) as send_email_mock: + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": True, + "sessions": [], + "feedback_text": None, + "trigger": None, + }, + ) + + assert response.status_code == 204 + send_email_mock.assert_called_once() + + +@pytest.mark.usefixtures("user", "smtp_config_set", "feedback_disabled") +def test_send_feedback_fail_disabled( + client: testclient.TestClient, +): + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": False, + "sessions": [], + "feedback_text": None, + "trigger": "test", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "FEEDBACK_NOT_ENABLED" + + +@pytest.mark.usefixtures("user", "smtp_config_not_set", "feedback_enabled") +def test_send_feedback_fail_missing_smtp( + client: testclient.TestClient, +): + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": False, + "sessions": [], + "feedback_text": None, + "trigger": "test", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SMTP_NOT_CONFIGURED" + + +@pytest.mark.usefixtures("user", "smtp_config_not_set", "feedback_disabled") +def test_send_feedback_fail_disabled_and_missing_smtp( + client: testclient.TestClient, +): + response = client.post( + "/api/v1/feedback", + json={ + "rating": "good", + "share_contact": False, + "sessions": [], + "feedback_text": None, + "trigger": "test", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SMTP_NOT_CONFIGURED" + + +@pytest.mark.usefixtures("admin") +def test_feedback_is_updated( + client: testclient.TestClient, +): + response = client.put( + "/api/v1/settings/configurations/global", + json={ + "feedback": { + "enabled": True, + "after_session": True, + "on_footer": True, + "on_session_card": True, + "interval": {"enabled": True, "hours_between_prompt": 24}, + "recipients": ["test@example.com"], + } + }, + ) + + assert response.status_code == 200 + + response = client.get("/api/v1/configurations/feedback") + assert response.status_code == 200 + assert response.json() == { + "enabled": True, + "after_session": True, + "on_footer": True, + "on_session_card": True, + "interval": {"enabled": True, "hours_between_prompt": 24}, + "recipients": ["test@example.com"], + } + + +@pytest.mark.usefixtures("admin", "smtp_config_not_set") +def test_activate_feedback_without_smtp( + client: testclient.TestClient, +): + response = client.put( + "/api/v1/settings/configurations/global", + json={ + "feedback": { + "enabled": True, + } + }, + ) + + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SMTP_NOT_CONFIGURED" + + +@pytest.mark.usefixtures("admin", "smtp_config_set") +def test_activate_feedback_without_recipients( + client: testclient.TestClient, +): + response = client.put( + "/api/v1/settings/configurations/global", + json={ + "feedback": { + "enabled": True, + "recipients": [], + } + }, + ) + + assert response.status_code == 400 + assert ( + response.json()["detail"]["err_code"] == "FEEDBACK_MISSING_RECIPIENTS" + ) + + +@pytest.mark.usefixtures("user", "smtp_config_not_set", "feedback_enabled") +def test_feedback_is_disabled_without_smtp(client: testclient.TestClient): + response = client.get("/api/v1/configurations/feedback") + assert response.status_code == 200 + assert response.json()["enabled"] is False diff --git a/docs/docs/admin/configure-for-your-org.md b/docs/docs/admin/configure-for-your-org.md index 1b90ceeeb9..099759671a 100644 --- a/docs/docs/admin/configure-for-your-org.md +++ b/docs/docs/admin/configure-for-your-org.md @@ -77,8 +77,7 @@ for learning about any potential issues users may be facing. There are several different types of feedback prompt: - After a session: Prompt the user for feedback after they have manually - terminated a session. You can reduce the percentage of users that are - prompted by changing the `percentage` field. + terminated a session. - On the session card: Show a feedback button on the session card. - In the footer: Show a feedback button in the footer. - Interval: Prompt the user for feedback after a certain number of hours have @@ -87,27 +86,18 @@ There are several different types of feedback prompt: ```yaml feedback: enabled: true - after_session: - enabled: true - percentage: 25 + after_session: true on_footer: true on_session_card: true interval: enabled: true hours_between_prompt: 168 - receivers: + recipients: # (1)! - test1@example.com - test2@example.com - anonymity_policy: ask_user ``` Prompts that are associated with a session automatically include anonymized metadata about the session. -By default, users can choose to share their contact information when providing -feedback. This can be disabled or made mandatory by changing the -`anonymity_policy` field from `ask_user` to `force_anonymous` or -`force_identified`, respectively. - -Feedback will be sent by email to the address specified in the `receiver` -field. +1. Feedback will be sent by email to all addresses specified here. diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 05d9d33201..8479de0692 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -18,7 +18,7 @@ import { HeaderComponent } from './general/header/header.component'; import { NavBarMenuComponent } from './general/nav-bar-menu/nav-bar-menu.component'; import { NoticeComponent } from './general/notice/notice.component'; import { PageLayoutService } from './page-layout/page-layout.service'; -import { FeedbackService } from './sessions/feedback/feedback.service'; +import { FeedbackWrapperService } from './sessions/feedback/feedback.service'; import { FullscreenService } from './sessions/service/fullscreen.service'; @Component({ @@ -45,7 +45,7 @@ export class AppComponent implements OnInit, AfterViewInit { public pageLayoutService: PageLayoutService, public fullscreenService: FullscreenService, private navBarService: NavBarService, - private feedbackService: FeedbackService, + private feedbackService: FeedbackWrapperService, ) { slugify.extend({ '.': '-' }); } diff --git a/frontend/src/app/general/footer/footer.component.html b/frontend/src/app/general/footer/footer.component.html index 16d9f8bcc1..20345c7aa0 100644 --- a/frontend/src/app/general/footer/footer.component.html +++ b/frontend/src/app/general/footer/footer.component.html @@ -19,14 +19,13 @@ }}. -
+
Imprint open_in_new -   Privacy statement open_in_new -   Contribute on GitHub open_in_new -   - @if (feedbackService.showOnFooter$ | async) { + @if ( + (feedbackService.feedbackConfig$ | async)?.on_footer && + authService.isLoggedIn() + ) { }
diff --git a/frontend/src/app/general/footer/footer.component.ts b/frontend/src/app/general/footer/footer.component.ts index c3af01c382..9d0cc4e6f2 100644 --- a/frontend/src/app/general/footer/footer.component.ts +++ b/frontend/src/app/general/footer/footer.component.ts @@ -8,7 +8,8 @@ import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MetadataService } from 'src/app/general/metadata/metadata.service'; import { VersionComponent } from 'src/app/general/metadata/version/version.component'; -import { FeedbackService } from '../../sessions/feedback/feedback.service'; +import { AuthenticationWrapperService } from 'src/app/services/auth/auth.service'; +import { FeedbackWrapperService } from '../../sessions/feedback/feedback.service'; @Component({ selector: 'app-footer', @@ -20,6 +21,7 @@ export class FooterComponent { constructor( public dialog: MatDialog, public metadataService: MetadataService, - public feedbackService: FeedbackService, + public feedbackService: FeedbackWrapperService, + public authService: AuthenticationWrapperService, ) {} } diff --git a/frontend/src/app/general/footer/footer.stories.ts b/frontend/src/app/general/footer/footer.stories.ts index 44a26fce43..b3158392c2 100644 --- a/frontend/src/app/general/footer/footer.stories.ts +++ b/frontend/src/app/general/footer/footer.stories.ts @@ -2,7 +2,14 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { Meta, StoryObj } from '@storybook/angular'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { AuthenticationWrapperService } from 'src/app/services/auth/auth.service'; +import { FeedbackWrapperService } from 'src/app/sessions/feedback/feedback.service'; +import { MockAuthenticationWrapperService } from 'src/storybook/auth'; +import { + mockFeedbackConfig, + MockFeedbackWrapperService, +} from 'src/storybook/feedback'; import { FooterComponent } from './footer.component'; const meta: Meta = { @@ -26,3 +33,21 @@ type Story = StoryObj; export const General: Story = { args: {}, }; + +export const WithFeedbackEnabled: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: FeedbackWrapperService, + useFactory: () => new MockFeedbackWrapperService(mockFeedbackConfig), + }, + { + provide: AuthenticationWrapperService, + useFactory: () => new MockAuthenticationWrapperService(), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/general/metadata/version/version.component.html b/frontend/src/app/general/metadata/version/version.component.html index becf7e83e7..4cdbba14fa 100644 --- a/frontend/src/app/general/metadata/version/version.component.html +++ b/frontend/src/app/general/metadata/version/version.component.html @@ -5,7 +5,7 @@
diff --git a/frontend/src/app/general/metadata/version/version.component.ts b/frontend/src/app/general/metadata/version/version.component.ts index ede9fd8ead..9d385ca42c 100644 --- a/frontend/src/app/general/metadata/version/version.component.ts +++ b/frontend/src/app/general/metadata/version/version.component.ts @@ -6,6 +6,7 @@ import { NgIf, AsyncPipe } from '@angular/common'; import { Component } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { environment } from 'src/environments/environment'; import { MetadataService } from '../metadata.service'; @Component({ @@ -19,4 +20,8 @@ export class VersionComponent { public metadataService: MetadataService, public dialog: MatDialog, ) {} + + get docsURL() { + return environment.docs_url; + } } diff --git a/frontend/src/app/general/nav-bar/nav-bar.service.ts b/frontend/src/app/general/nav-bar/nav-bar.service.ts index 7d37118ca6..2cf9156c46 100644 --- a/frontend/src/app/general/nav-bar/nav-bar.service.ts +++ b/frontend/src/app/general/nav-bar/nav-bar.service.ts @@ -76,6 +76,7 @@ export class NavBarService { private _linkMap: Record = { prometheus: environment.prometheus_url, grafana: environment.grafana_url, + smtp_mock: environment.smtp_mock_url, documentation: environment.docs_url + '/', }; } diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index a6d2b463d5..929e1a2d80 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -54,13 +54,10 @@ model/diagram-metadata.ts model/environment-value.ts model/environment-value1.ts model/event-type.ts -model/feedback-anonymity-policy.ts model/feedback-configuration-input.ts model/feedback-configuration-output.ts model/feedback-interval-configuration-input.ts model/feedback-interval-configuration-output.ts -model/feedback-probability-configuration-input.ts -model/feedback-probability-configuration-output.ts model/feedback-rating.ts model/feedback.ts model/file-tree.ts @@ -89,6 +86,9 @@ model/message.ts model/metadata-configuration-input.ts model/metadata-configuration-output.ts model/metadata.ts +model/minimal-tool-session-connection-method.ts +model/minimal-tool-version-with-tool.ts +model/minimal-tool.ts model/model-artifact-status.ts model/models.ts model/navbar-configuration-input-external-links-inner.ts @@ -141,8 +141,7 @@ model/role.ts model/session-connection-information.ts model/session-monitoring-input.ts model/session-monitoring-output.ts -model/session-ports-input.ts -model/session-ports-output.ts +model/session-ports.ts model/session-provisioning-request.ts model/session-sharing.ts model/session-tool-configuration-input.ts @@ -160,7 +159,6 @@ model/t4-c-repository.ts model/token-request.ts model/tool-backup-configuration-input.ts model/tool-backup-configuration-output.ts -model/tool-input.ts model/tool-integrations-input.ts model/tool-integrations-output.ts model/tool-model-provisioning-input.ts @@ -168,13 +166,11 @@ model/tool-model-provisioning-output.ts model/tool-model-restrictions.ts model/tool-model.ts model/tool-nature.ts -model/tool-output.ts model/tool-session-configuration-input.ts model/tool-session-configuration-output.ts model/tool-session-connection-input-methods-inner.ts model/tool-session-connection-input.ts -model/tool-session-connection-method-input.ts -model/tool-session-connection-method-output.ts +model/tool-session-connection-method.ts model/tool-session-connection-output-methods-inner.ts model/tool-session-connection-output.ts model/tool-session-environment-input.ts @@ -184,9 +180,9 @@ model/tool-session-sharing-configuration-input.ts model/tool-session-sharing-configuration-output.ts model/tool-version-configuration-input.ts model/tool-version-configuration-output.ts -model/tool-version-with-tool-input.ts -model/tool-version-with-tool-output.ts +model/tool-version-with-tool.ts model/tool-version.ts +model/tool.ts model/toolmodel-status.ts model/user-metadata.ts model/user-token-with-password.ts diff --git a/frontend/src/app/openapi/api/feedback.service.ts b/frontend/src/app/openapi/api/feedback.service.ts index 74c1952cc7..b86b031e3b 100644 --- a/frontend/src/app/openapi/api/feedback.service.ts +++ b/frontend/src/app/openapi/api/feedback.service.ts @@ -97,14 +97,14 @@ export class FeedbackService { } /** - * Get Feedback + * Get Feedback Configuration * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getFeedback(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public getFeedback(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getFeedback(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getFeedback(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public getFeedbackConfiguration(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getFeedbackConfiguration(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getFeedbackConfiguration(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getFeedbackConfiguration(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -142,7 +142,7 @@ export class FeedbackService { } } - let localVarPath = `/api/v1/feedback`; + let localVarPath = `/api/v1/configurations/feedback`; return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, diff --git a/frontend/src/app/openapi/api/tools.service.ts b/frontend/src/app/openapi/api/tools.service.ts index b1be2acad9..40711b7bd3 100644 --- a/frontend/src/app/openapi/api/tools.service.ts +++ b/frontend/src/app/openapi/api/tools.service.ts @@ -33,13 +33,13 @@ import { CreateToolVersionOutput } from '../model/create-tool-version-output'; // @ts-ignore import { HTTPValidationError } from '../model/http-validation-error'; // @ts-ignore -import { ToolNature } from '../model/tool-nature'; +import { Tool } from '../model/tool'; // @ts-ignore -import { ToolOutput } from '../model/tool-output'; +import { ToolNature } from '../model/tool-nature'; // @ts-ignore import { ToolVersion } from '../model/tool-version'; // @ts-ignore -import { ToolVersionWithToolOutput } from '../model/tool-version-with-tool-output'; +import { ToolVersionWithTool } from '../model/tool-version-with-tool'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -119,9 +119,9 @@ export class ToolsService { * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public createTool(createToolInput: CreateToolInput, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public createTool(createToolInput: CreateToolInput, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public createTool(createToolInput: CreateToolInput, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createTool(createToolInput: CreateToolInput, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createTool(createToolInput: CreateToolInput, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createTool(createToolInput: CreateToolInput, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public createTool(createToolInput: CreateToolInput, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (createToolInput === null || createToolInput === undefined) { throw new Error('Required parameter createToolInput was null or undefined when calling createTool.'); @@ -180,7 +180,7 @@ export class ToolsService { } let localVarPath = `/api/v1/tools`; - return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, body: createToolInput, @@ -800,9 +800,9 @@ export class ToolsService { * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getToolById(toolId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public getToolById(toolId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getToolById(toolId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getToolById(toolId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getToolById(toolId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getToolById(toolId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public getToolById(toolId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (toolId === null || toolId === undefined) { throw new Error('Required parameter toolId was null or undefined when calling getToolById.'); @@ -852,7 +852,7 @@ export class ToolsService { } let localVarPath = `/api/v1/tools/${this.configuration.encodeParam({name: "toolId", value: toolId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, @@ -1162,9 +1162,9 @@ export class ToolsService { * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getTools(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getTools(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getTools(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getTools(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getTools(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getTools(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; public getTools(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -1211,7 +1211,7 @@ export class ToolsService { } let localVarPath = `/api/v1/tools`; - return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, @@ -1229,9 +1229,9 @@ export class ToolsService { * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getVersionsForAllTools(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getVersionsForAllTools(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getVersionsForAllTools(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getVersionsForAllTools(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getVersionsForAllTools(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getVersionsForAllTools(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; public getVersionsForAllTools(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -1278,7 +1278,7 @@ export class ToolsService { } let localVarPath = `/api/v1/tools/*/versions`; - return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, @@ -1298,9 +1298,9 @@ export class ToolsService { * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateTool(toolId: number, createToolInput: CreateToolInput, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public updateTool(toolId: number, createToolInput: CreateToolInput, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (toolId === null || toolId === undefined) { throw new Error('Required parameter toolId was null or undefined when calling updateTool.'); @@ -1362,7 +1362,7 @@ export class ToolsService { } let localVarPath = `/api/v1/tools/${this.configuration.encodeParam({name: "toolId", value: toolId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}`; - return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, body: createToolInput, diff --git a/frontend/src/app/openapi/model/anonymized-session.ts b/frontend/src/app/openapi/model/anonymized-session.ts index f3b5c04e54..17255c26bc 100644 --- a/frontend/src/app/openapi/model/anonymized-session.ts +++ b/frontend/src/app/openapi/model/anonymized-session.ts @@ -9,21 +9,20 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { MinimalToolSessionConnectionMethod } from './minimal-tool-session-connection-method'; import { SessionType } from './session-type'; import { Message } from './message'; -import { ToolSessionConnectionMethodInput } from './tool-session-connection-method-input'; -import { ToolVersionWithToolInput } from './tool-version-with-tool-input'; +import { MinimalToolVersionWithTool } from './minimal-tool-version-with-tool'; export interface AnonymizedSession { id: string; type: SessionType; created_at: string; - version: ToolVersionWithToolInput; + version: MinimalToolVersionWithTool; state?: string; warnings?: Array; - connection_method_id: string; - connection_method?: ToolSessionConnectionMethodInput | null; + connection_method?: MinimalToolSessionConnectionMethod | null; } export namespace AnonymizedSession { } diff --git a/frontend/src/app/openapi/model/built-in-link-item.ts b/frontend/src/app/openapi/model/built-in-link-item.ts index d122038a7d..2ffd6b9f3a 100644 --- a/frontend/src/app/openapi/model/built-in-link-item.ts +++ b/frontend/src/app/openapi/model/built-in-link-item.ts @@ -11,11 +11,12 @@ -export type BuiltInLinkItem = 'grafana' | 'prometheus' | 'documentation'; +export type BuiltInLinkItem = 'grafana' | 'prometheus' | 'documentation' | 'smtp_mock'; export const BuiltInLinkItem = { Grafana: 'grafana' as BuiltInLinkItem, Prometheus: 'prometheus' as BuiltInLinkItem, - Documentation: 'documentation' as BuiltInLinkItem + Documentation: 'documentation' as BuiltInLinkItem, + SmtpMock: 'smtp_mock' as BuiltInLinkItem }; diff --git a/frontend/src/app/openapi/model/feedback-anonymity-policy.ts b/frontend/src/app/openapi/model/feedback-anonymity-policy.ts deleted file mode 100644 index 0a50781fae..0000000000 --- a/frontend/src/app/openapi/model/feedback-anonymity-policy.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - * - * Capella Collaboration - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - + To generate a new version, run `make openapi` in the root directory of this repository. - */ - - - -export type FeedbackAnonymityPolicy = 'force_anonymous' | 'force_identified' | 'ask_user'; - -export const FeedbackAnonymityPolicy = { - ForceAnonymous: 'force_anonymous' as FeedbackAnonymityPolicy, - ForceIdentified: 'force_identified' as FeedbackAnonymityPolicy, - AskUser: 'ask_user' as FeedbackAnonymityPolicy -}; - diff --git a/frontend/src/app/openapi/model/feedback-configuration-input.ts b/frontend/src/app/openapi/model/feedback-configuration-input.ts index c89a2ec4a7..921877fd53 100644 --- a/frontend/src/app/openapi/model/feedback-configuration-input.ts +++ b/frontend/src/app/openapi/model/feedback-configuration-input.ts @@ -9,8 +9,6 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ -import { FeedbackAnonymityPolicy } from './feedback-anonymity-policy'; -import { FeedbackProbabilityConfigurationInput } from './feedback-probability-configuration-input'; import { FeedbackIntervalConfigurationInput } from './feedback-interval-configuration-input'; @@ -22,7 +20,7 @@ export interface FeedbackConfigurationInput { /** * If a feedback form is shown after terminating a session. */ - after_session?: FeedbackProbabilityConfigurationInput; + after_session?: boolean; /** * Should a general feedback button be shown. */ @@ -38,13 +36,6 @@ export interface FeedbackConfigurationInput { /** * Email addresses to send feedback to. */ - receivers?: Array; - /** - * If feedback should be anonymous or identified. - */ - anonymity_policy?: FeedbackAnonymityPolicy; + recipients?: Array; } -export namespace FeedbackConfigurationInput { -} - diff --git a/frontend/src/app/openapi/model/feedback-configuration-output.ts b/frontend/src/app/openapi/model/feedback-configuration-output.ts index ceff380a10..2f69bf3d7c 100644 --- a/frontend/src/app/openapi/model/feedback-configuration-output.ts +++ b/frontend/src/app/openapi/model/feedback-configuration-output.ts @@ -9,9 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ -import { FeedbackAnonymityPolicy } from './feedback-anonymity-policy'; import { FeedbackIntervalConfigurationOutput } from './feedback-interval-configuration-output'; -import { FeedbackProbabilityConfigurationOutput } from './feedback-probability-configuration-output'; export interface FeedbackConfigurationOutput { @@ -22,7 +20,7 @@ export interface FeedbackConfigurationOutput { /** * If a feedback form is shown after terminating a session. */ - after_session: FeedbackProbabilityConfigurationOutput; + after_session: boolean; /** * Should a general feedback button be shown. */ @@ -38,13 +36,6 @@ export interface FeedbackConfigurationOutput { /** * Email addresses to send feedback to. */ - receivers: Array; - /** - * If feedback should be anonymous or identified. - */ - anonymity_policy: FeedbackAnonymityPolicy; + recipients: Array; } -export namespace FeedbackConfigurationOutput { -} - diff --git a/frontend/src/app/openapi/model/feedback.ts b/frontend/src/app/openapi/model/feedback.ts index 236bdde481..6494ffec6b 100644 --- a/frontend/src/app/openapi/model/feedback.ts +++ b/frontend/src/app/openapi/model/feedback.ts @@ -27,10 +27,7 @@ export interface Feedback { * The sessions the feedback is for */ sessions: Array; - /** - * What triggered the feedback form - */ - trigger: string; + trigger: string | null; } export namespace Feedback { } diff --git a/frontend/src/app/openapi/model/session-ports-input.ts b/frontend/src/app/openapi/model/minimal-tool-session-connection-method.ts similarity index 74% rename from frontend/src/app/openapi/model/session-ports-input.ts rename to frontend/src/app/openapi/model/minimal-tool-session-connection-method.ts index d81fbbb5d2..61d2d78761 100644 --- a/frontend/src/app/openapi/model/session-ports-input.ts +++ b/frontend/src/app/openapi/model/minimal-tool-session-connection-method.ts @@ -11,10 +11,8 @@ -export interface SessionPortsInput { - /** - * Port of the metrics endpoint in the container. - */ - metrics?: number; +export interface MinimalToolSessionConnectionMethod { + id: string; + name: string; } diff --git a/frontend/src/app/openapi/model/feedback-probability-configuration-output.ts b/frontend/src/app/openapi/model/minimal-tool-version-with-tool.ts similarity index 59% rename from frontend/src/app/openapi/model/feedback-probability-configuration-output.ts rename to frontend/src/app/openapi/model/minimal-tool-version-with-tool.ts index 9a79c3bdb7..9e2d7e031a 100644 --- a/frontend/src/app/openapi/model/feedback-probability-configuration-output.ts +++ b/frontend/src/app/openapi/model/minimal-tool-version-with-tool.ts @@ -9,16 +9,12 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { MinimalTool } from './minimal-tool'; -export interface FeedbackProbabilityConfigurationOutput { - /** - * Whether the feedback probability is enabled. - */ - enabled: boolean; - /** - * The percentage of users that will be asked for feedback. - */ - percentage: number; +export interface MinimalToolVersionWithTool { + id: number; + name?: string; + tool: MinimalTool; } diff --git a/frontend/src/app/openapi/model/feedback-probability-configuration-input.ts b/frontend/src/app/openapi/model/minimal-tool.ts similarity index 59% rename from frontend/src/app/openapi/model/feedback-probability-configuration-input.ts rename to frontend/src/app/openapi/model/minimal-tool.ts index 0f6c695202..57535f290a 100644 --- a/frontend/src/app/openapi/model/feedback-probability-configuration-input.ts +++ b/frontend/src/app/openapi/model/minimal-tool.ts @@ -11,14 +11,8 @@ -export interface FeedbackProbabilityConfigurationInput { - /** - * Whether the feedback probability is enabled. - */ - enabled?: boolean; - /** - * The percentage of users that will be asked for feedback. - */ - percentage?: number; +export interface MinimalTool { + id: number; + name?: string; } diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index b248ff37fd..a0c996257d 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -35,13 +35,10 @@ export * from './environment-value'; export * from './environment-value1'; export * from './event-type'; export * from './feedback'; -export * from './feedback-anonymity-policy'; export * from './feedback-configuration-input'; export * from './feedback-configuration-output'; export * from './feedback-interval-configuration-input'; export * from './feedback-interval-configuration-output'; -export * from './feedback-probability-configuration-input'; -export * from './feedback-probability-configuration-output'; export * from './feedback-rating'; export * from './file-tree'; export * from './file-type'; @@ -69,6 +66,9 @@ export * from './message'; export * from './metadata'; export * from './metadata-configuration-input'; export * from './metadata-configuration-output'; +export * from './minimal-tool'; +export * from './minimal-tool-session-connection-method'; +export * from './minimal-tool-version-with-tool'; export * from './model-artifact-status'; export * from './navbar-configuration-input'; export * from './navbar-configuration-input-external-links-inner'; @@ -121,8 +121,7 @@ export * from './session'; export * from './session-connection-information'; export * from './session-monitoring-input'; export * from './session-monitoring-output'; -export * from './session-ports-input'; -export * from './session-ports-output'; +export * from './session-ports'; export * from './session-provisioning-request'; export * from './session-sharing'; export * from './session-tool-configuration-input'; @@ -137,9 +136,9 @@ export * from './t4-c-model'; export * from './t4-c-repository'; export * from './t4-c-repository-status'; export * from './token-request'; +export * from './tool'; export * from './tool-backup-configuration-input'; export * from './tool-backup-configuration-output'; -export * from './tool-input'; export * from './tool-integrations-input'; export * from './tool-integrations-output'; export * from './tool-model'; @@ -147,13 +146,11 @@ export * from './tool-model-provisioning-input'; export * from './tool-model-provisioning-output'; export * from './tool-model-restrictions'; export * from './tool-nature'; -export * from './tool-output'; export * from './tool-session-configuration-input'; export * from './tool-session-configuration-output'; export * from './tool-session-connection-input'; export * from './tool-session-connection-input-methods-inner'; -export * from './tool-session-connection-method-input'; -export * from './tool-session-connection-method-output'; +export * from './tool-session-connection-method'; export * from './tool-session-connection-output'; export * from './tool-session-connection-output-methods-inner'; export * from './tool-session-environment-input'; @@ -164,8 +161,7 @@ export * from './tool-session-sharing-configuration-output'; export * from './tool-version'; export * from './tool-version-configuration-input'; export * from './tool-version-configuration-output'; -export * from './tool-version-with-tool-input'; -export * from './tool-version-with-tool-output'; +export * from './tool-version-with-tool'; export * from './toolmodel-status'; export * from './user'; export * from './user-metadata'; diff --git a/frontend/src/app/openapi/model/session-ports-output.ts b/frontend/src/app/openapi/model/session-ports.ts similarity index 92% rename from frontend/src/app/openapi/model/session-ports-output.ts rename to frontend/src/app/openapi/model/session-ports.ts index 073bbe1275..4d0a5ffac5 100644 --- a/frontend/src/app/openapi/model/session-ports-output.ts +++ b/frontend/src/app/openapi/model/session-ports.ts @@ -11,7 +11,7 @@ -export interface SessionPortsOutput { +export interface SessionPorts { /** * Port of the metrics endpoint in the container. */ diff --git a/frontend/src/app/openapi/model/session.ts b/frontend/src/app/openapi/model/session.ts index 661e2296e9..4a2a348356 100644 --- a/frontend/src/app/openapi/model/session.ts +++ b/frontend/src/app/openapi/model/session.ts @@ -11,9 +11,9 @@ import { BaseUser } from './base-user'; import { SessionType } from './session-type'; -import { ToolVersionWithToolOutput } from './tool-version-with-tool-output'; import { Message } from './message'; -import { ToolSessionConnectionMethodOutput } from './tool-session-connection-method-output'; +import { ToolVersionWithTool } from './tool-version-with-tool'; +import { ToolSessionConnectionMethod } from './tool-session-connection-method'; import { SessionSharing } from './session-sharing'; @@ -22,12 +22,12 @@ export interface Session { type: SessionType; created_at: string; owner: BaseUser; - version: ToolVersionWithToolOutput; + version: ToolVersionWithTool; state: string; warnings: Array; last_seen: string; connection_method_id: string; - connection_method: ToolSessionConnectionMethodOutput | null; + connection_method: ToolSessionConnectionMethod | null; shared_with: Array; } export namespace Session { diff --git a/frontend/src/app/openapi/model/tool-input.ts b/frontend/src/app/openapi/model/tool-input.ts deleted file mode 100644 index 978d11c308..0000000000 --- a/frontend/src/app/openapi/model/tool-input.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - * - * Capella Collaboration - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - + To generate a new version, run `make openapi` in the root directory of this repository. - */ - -import { ToolSessionConfigurationInput } from './tool-session-configuration-input'; -import { ToolIntegrationsInput } from './tool-integrations-input'; - - -export interface ToolInput { - /** - * Unique identifier of the resource. - */ - id: number; - name?: string; - integrations?: ToolIntegrationsInput; - config?: ToolSessionConfigurationInput; -} - diff --git a/frontend/src/app/openapi/model/tool-model.ts b/frontend/src/app/openapi/model/tool-model.ts index d61619a15b..d20807d6ad 100644 --- a/frontend/src/app/openapi/model/tool-model.ts +++ b/frontend/src/app/openapi/model/tool-model.ts @@ -10,10 +10,10 @@ */ import { GitModel } from './git-model'; -import { ToolOutput } from './tool-output'; import { T4CModel } from './t4-c-model'; import { ToolVersion } from './tool-version'; import { ToolModelRestrictions } from './tool-model-restrictions'; +import { Tool } from './tool'; import { ToolNature } from './tool-nature'; @@ -23,7 +23,7 @@ export interface ToolModel { name: string; description: string; display_order: number | null; - tool: ToolOutput; + tool: Tool; version: ToolVersion | null; nature: ToolNature | null; git_models: Array | null; diff --git a/frontend/src/app/openapi/model/tool-session-connection-method-input.ts b/frontend/src/app/openapi/model/tool-session-connection-method-input.ts deleted file mode 100644 index 976b1748c1..0000000000 --- a/frontend/src/app/openapi/model/tool-session-connection-method-input.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - * - * Capella Collaboration - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - + To generate a new version, run `make openapi` in the root directory of this repository. - */ - -import { EnvironmentValue } from './environment-value'; -import { SessionPortsInput } from './session-ports-input'; -import { ToolSessionSharingConfigurationInput } from './tool-session-sharing-configuration-input'; - - -export interface ToolSessionConnectionMethodInput { - id?: string; - type: string; - name?: string; - description?: string; - ports: SessionPortsInput; - /** - * Connection method specific environment variables. Check the global environment field for more information. - */ - environment?: { [key: string]: EnvironmentValue; }; - sharing?: ToolSessionSharingConfigurationInput; -} - diff --git a/frontend/src/app/openapi/model/tool-session-connection-method-output.ts b/frontend/src/app/openapi/model/tool-session-connection-method.ts similarity index 85% rename from frontend/src/app/openapi/model/tool-session-connection-method-output.ts rename to frontend/src/app/openapi/model/tool-session-connection-method.ts index 88d92f90fc..41ba6a5e57 100644 --- a/frontend/src/app/openapi/model/tool-session-connection-method-output.ts +++ b/frontend/src/app/openapi/model/tool-session-connection-method.ts @@ -9,17 +9,17 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ -import { SessionPortsOutput } from './session-ports-output'; +import { SessionPorts } from './session-ports'; import { ToolSessionSharingConfigurationOutput } from './tool-session-sharing-configuration-output'; import { EnvironmentValue1 } from './environment-value1'; -export interface ToolSessionConnectionMethodOutput { +export interface ToolSessionConnectionMethod { id: string; type: string; name: string; description: string; - ports: SessionPortsOutput; + ports: SessionPorts; /** * Connection method specific environment variables. Check the global environment field for more information. */ diff --git a/frontend/src/app/openapi/model/tool-version-with-tool-input.ts b/frontend/src/app/openapi/model/tool-version-with-tool-input.ts deleted file mode 100644 index d03035a849..0000000000 --- a/frontend/src/app/openapi/model/tool-version-with-tool-input.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - * - * Capella Collaboration - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. - + To generate a new version, run `make openapi` in the root directory of this repository. - */ - -import { ToolVersionConfigurationInput } from './tool-version-configuration-input'; -import { ToolInput } from './tool-input'; - - -export interface ToolVersionWithToolInput { - /** - * Unique identifier of the resource. - */ - id: number; - name?: string; - config?: ToolVersionConfigurationInput; - tool: ToolInput; -} - diff --git a/frontend/src/app/openapi/model/tool-version-with-tool-output.ts b/frontend/src/app/openapi/model/tool-version-with-tool.ts similarity index 84% rename from frontend/src/app/openapi/model/tool-version-with-tool-output.ts rename to frontend/src/app/openapi/model/tool-version-with-tool.ts index 1dec33004c..399bef2bf3 100644 --- a/frontend/src/app/openapi/model/tool-version-with-tool-output.ts +++ b/frontend/src/app/openapi/model/tool-version-with-tool.ts @@ -10,16 +10,16 @@ */ import { ToolVersionConfigurationOutput } from './tool-version-configuration-output'; -import { ToolOutput } from './tool-output'; +import { Tool } from './tool'; -export interface ToolVersionWithToolOutput { +export interface ToolVersionWithTool { /** * Unique identifier of the resource. */ id: number; name: string; config: ToolVersionConfigurationOutput; - tool: ToolOutput; + tool: Tool; } diff --git a/frontend/src/app/openapi/model/tool-output.ts b/frontend/src/app/openapi/model/tool.ts similarity index 95% rename from frontend/src/app/openapi/model/tool-output.ts rename to frontend/src/app/openapi/model/tool.ts index 6588b4a470..0d1c3ee13b 100644 --- a/frontend/src/app/openapi/model/tool-output.ts +++ b/frontend/src/app/openapi/model/tool.ts @@ -13,7 +13,7 @@ import { ToolSessionConfigurationOutput } from './tool-session-configuration-out import { ToolIntegrationsOutput } from './tool-integrations-output'; -export interface ToolOutput { +export interface Tool { /** * Unique identifier of the resource. */ diff --git a/frontend/src/app/projects/models/init-model/init-model.component.ts b/frontend/src/app/projects/models/init-model/init-model.component.ts index eb60c57ff4..9668d8d686 100644 --- a/frontend/src/app/projects/models/init-model/init-model.component.ts +++ b/frontend/src/app/projects/models/init-model/init-model.component.ts @@ -20,11 +20,11 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { combineLatest, filter, map, switchMap, tap } from 'rxjs'; import { SKIP_ERROR_HANDLING_CONTEXT } from 'src/app/general/error-handling/error-handling.interceptor'; import { - ToolOutput, ToolModel, ToolNature, ToolVersion, ToolsService, + Tool, } from 'src/app/openapi'; import { ModelWrapperService } from 'src/app/projects/models/service/model.service'; import { GitModelService } from '../../project-detail/model-overview/model-detail/git-model.service'; @@ -92,7 +92,7 @@ export class InitModelComponent implements OnInit { } }), map((model: ToolModel) => model.tool), - switchMap((tool: ToolOutput) => + switchMap((tool: Tool) => combineLatest([ this.toolsService.getToolVersions(tool.id, undefined, undefined, { context: SKIP_ERROR_HANDLING_CONTEXT, diff --git a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html index d01855ea2a..58502cd9bc 100644 --- a/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html +++ b/frontend/src/app/sessions/feedback/feedback-dialog/feedback-dialog.component.html @@ -13,67 +13,64 @@

How were your sessions? }

-
-
-
+
+
+ @for (rating of ratings; track rating) { - - -
+ } +
- @if ( - feedbackForm.get("rating")?.value === "bad" || - feedbackForm.get("rating")?.value === "okay" - ) { - + @if (feedbackForm.get("rating")?.value) { + + @if (feedbackForm.get("rating")?.value === "good") { + What was good? + } @else { What can we do better? - - Try and be as specific as possible - - - @if ((feedbackService.anonymityPolicy$ | async) === "ask_user") { -
- I want to be contacted about this -
} - } + + Try to be as specific as possible. +
+ } + + + Share user information + -
- @if ( - (feedbackService.anonymityPolicy$ | async) === "force_identified" || - feedbackForm.get("shareContact")?.value - ) { +
+
+ @if (feedbackForm.get("shareContact")?.value) { privacy_tip Your contact information will be shared with @@ -85,20 +82,23 @@

} @else { security - Your feedback will be anonymous. + Your feedback will be anonymous. }

- - @if (data.sessions.length > 0) { -
+
+ @if (data.sessions.length > 0) { storage - Your feedback includes anonymized session details. -
- } + @if (feedbackForm.get("shareContact")?.value) { + Your feedback includes session details. + } @else { + Your feedback includes anonymized session details. + } + } +
-
- +
+