From 5a3fcbdb817cd536b5491e63ca74a0628ea877d0 Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:03:58 -0700 Subject: [PATCH 01/19] Adding docker type to github utils (#5348) --- utils/github_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/github_utils.py b/utils/github_utils.py index a9534cb42c7b..837ab78fbe12 100755 --- a/utils/github_utils.py +++ b/utils/github_utils.py @@ -99,6 +99,7 @@ def release_notes(pull_request_number: int) -> NoReturn: "techdebt": "", "tests": "", "improvement": "", + "docker": "", } click.echo(f"Fetching list of merged PRs since #{pull_request_number}...") From 1a523665b46cd2f760da16a4db7317d02df65744 Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:07:30 -0700 Subject: [PATCH 02/19] Allow list of strings in plugin metadata (#5350) --- src/dispatch/plugin/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/plugin/models.py b/src/dispatch/plugin/models.py index 1f043fff5d19..e7a6b3760ff1 100644 --- a/src/dispatch/plugin/models.py +++ b/src/dispatch/plugin/models.py @@ -219,7 +219,7 @@ class PluginInstanceUpdate(PluginBase): class KeyValue(DispatchBase): key: str - value: str + value: str | List[str] class PluginMetadata(DispatchBase): From 581235c1dcfd652e3e49016000ddf1f8351d4ead Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:38:24 -0700 Subject: [PATCH 03/19] Only collect participant emails if individual exists (#5351) --- src/dispatch/search/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dispatch/search/views.py b/src/dispatch/search/views.py index 271f39b07bc0..bd2dc101acdf 100644 --- a/src/dispatch/search/views.py +++ b/src/dispatch/search/views.py @@ -40,7 +40,9 @@ def search( current_user_email = common["current_user"].email for incident in results["Incident"]: participant_emails: list[str] = [ - participant.individual.email for participant in incident.participants + participant.individual.email + for participant in incident.participants + if participant.individual ] if ( incident.project in admin_projects From 8efea5c48f93500a1c8b6af2f375735737fa57b0 Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:35:23 -0700 Subject: [PATCH 04/19] Add Dispatch link to Jira ticket (#5352) --- src/dispatch/plugins/dispatch_core/plugin.py | 1 + src/dispatch/plugins/dispatch_jira/plugin.py | 8 +++ .../plugins/dispatch_jira/templates.py | 2 + src/dispatch/ticket/flows.py | 53 ++++++++++--------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/dispatch/plugins/dispatch_core/plugin.py b/src/dispatch/plugins/dispatch_core/plugin.py index 84593ce87b65..2dbeb354a615 100644 --- a/src/dispatch/plugins/dispatch_core/plugin.py +++ b/src/dispatch/plugins/dispatch_core/plugin.py @@ -258,6 +258,7 @@ def update_case_ticket( # reporter_email: str, document_weblink: str, storage_weblink: str, + dispatch_weblink: str, case_type_plugin_metadata: dict = None, ): """Updates a Dispatch case ticket.""" diff --git a/src/dispatch/plugins/dispatch_jira/plugin.py b/src/dispatch/plugins/dispatch_jira/plugin.py index 42cde3f13a0b..8e427d20cec4 100644 --- a/src/dispatch/plugins/dispatch_jira/plugin.py +++ b/src/dispatch/plugins/dispatch_jira/plugin.py @@ -155,6 +155,7 @@ def create_incident_issue_fields( document_weblink: str, storage_weblink: str, conference_weblink: str, + dispatch_weblink: str, cost: float, ): """Creates Jira issue fields.""" @@ -192,6 +193,7 @@ def create_incident_issue_fields( conference_weblink=conference_weblink, conversation_weblink=conversation_weblink, storage_weblink=storage_weblink, + dispatch_weblink=dispatch_weblink, ) issue_fields.update({"description": description}) @@ -210,6 +212,7 @@ def create_case_issue_fields( assignee_username: str, document_weblink: str, storage_weblink: str, + dispatch_weblink: str, ): """Creates Jira issue fields.""" issue_fields = {} @@ -226,6 +229,7 @@ def create_case_issue_fields( document_weblink=document_weblink, resolution=resolution, storage_weblink=storage_weblink, + dispatch_weblink=dispatch_weblink, ) issue_fields.update({"description": description}) @@ -329,6 +333,7 @@ def update( document_weblink: str, storage_weblink: str, conference_weblink: str, + dispatch_weblink: str, cost: float, incident_type_plugin_metadata: dict = None, ): @@ -357,6 +362,7 @@ def update( document_weblink=document_weblink, storage_weblink=storage_weblink, conference_weblink=conference_weblink, + dispatch_weblink=dispatch_weblink, cost=cost, ) @@ -481,6 +487,7 @@ def update_case_ticket( # reporter_email: str, document_weblink: str, storage_weblink: str, + dispatch_weblink: str, case_type_plugin_metadata: dict = None, ): """Updates a case Jira issue.""" @@ -506,6 +513,7 @@ def update_case_ticket( assignee_username=assignee_username, document_weblink=document_weblink, storage_weblink=storage_weblink, + dispatch_weblink=dispatch_weblink, ) return update(self.configuration, client, issue, issue_fields, status) diff --git a/src/dispatch/plugins/dispatch_jira/templates.py b/src/dispatch/plugins/dispatch_jira/templates.py index b652ccee6cdb..1437478766fa 100644 --- a/src/dispatch/plugins/dispatch_jira/templates.py +++ b/src/dispatch/plugins/dispatch_jira/templates.py @@ -9,6 +9,7 @@ Cost: {{cost}} *Incident Resources* +[Dispatch Link|{{dispatch_weblink}}] [Conversation|{{conversation_weblink}}] [Investigation Document|{{document_weblink}}] [Storage|{{storage_weblink}}] @@ -41,6 +42,7 @@ Priority: {{case_priority}} *Case Resources* +[Dispatch Link|{{dispatch_weblink}}] [Investigation Document|{{document_weblink}}] [Storage|{{storage_weblink}}] diff --git a/src/dispatch/ticket/flows.py b/src/dispatch/ticket/flows.py index 9b6c045e8594..a506e478bcb2 100644 --- a/src/dispatch/ticket/flows.py +++ b/src/dispatch/ticket/flows.py @@ -13,6 +13,7 @@ from dispatch.participant import service as participant_service from dispatch.plugin import service as plugin_service from dispatch.task.models import Task +from dispatch.config import DISPATCH_UI_URL from .models import Ticket, TicketCreate from .service import create @@ -111,20 +112,21 @@ def update_incident_ticket( # we update the external incident ticket try: plugin.instance.update( - incident.ticket.resource_id, - title, - description, - incident.incident_type.name, - incident.incident_severity.name, - incident.incident_priority.name, - incident.status.lower(), - incident.commander.individual.email, - incident.reporter.individual.email, - resolve_attr(incident, "conversation.weblink"), - resolve_attr(incident, "incident_document.weblink"), - resolve_attr(incident, "storage.weblink"), - resolve_attr(incident, "conference.weblink"), - total_cost, + ticket_id=incident.ticket.resource_id, + title=title, + description=description, + incident_type=incident.incident_type.name, + incident_severity=incident.incident_severity.name, + incident_priority=incident.incident_priority.name, + status=incident.status.lower(), + commander_email=incident.commander.individual.email, + reporter_email=incident.reporter.individual.email, + conversation_weblink=resolve_attr(incident, "conversation.weblink"), + document_weblink=resolve_attr(incident, "incident_document.weblink"), + storage_weblink=resolve_attr(incident, "storage.weblink"), + conference_weblink=resolve_attr(incident, "conference.weblink"), + dispatch_weblink=f"{DISPATCH_UI_URL}/{incident.project.organization.slug}/incidents/{incident.name}", + cost=total_cost, incident_type_plugin_metadata=incident_type_plugin_metadata, ) except Exception as e: @@ -230,17 +232,18 @@ def update_case_ticket( # we update the external case ticket try: plugin.instance.update_case_ticket( - case.ticket.resource_id, - title, - description, - case.resolution, - case.case_type.name, - case.case_severity.name, - case.case_priority.name, - case.status.lower(), - case.assignee.individual.email, - case_document_weblink, - case_storage_weblink, + ticket_id=case.ticket.resource_id, + title=title, + description=description, + resolution=case.resolution, + case_type=case.case_type.name, + case_severity=case.case_severity.name, + case_priority=case.case_priority.name, + status=case.status.lower(), + assignee_email=case.assignee.individual.email, + document_weblink=case_document_weblink, + storage_weblink=case_storage_weblink, + dispatch_weblink=f"{DISPATCH_UI_URL}/{case.project.organization.slug}/cases/{case.name}", case_type_plugin_metadata=case_type_plugin_metadata, ) except Exception as e: From e08bb05899e9d97b8ebaa4536827bd7b7ee7efe6 Mon Sep 17 00:00:00 2001 From: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:16:20 -0700 Subject: [PATCH 05/19] Checks if the message is a dict before saving it to the genai analysis field in the case model (#5357) --- src/dispatch/plugins/dispatch_slack/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index dd93e2c22c7a..e2968d94cd48 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -107,7 +107,9 @@ def create_threaded(self, case: Case, conversation_id: str, db_session: Session) client=client, config=self.configuration, ) - if message: + if message and isinstance(message, dict): + # we update the genai_analysis field in the case model with the message if it's a dict + # if the message is a string, it means there was an error generating the analysis case.genai_analysis = message if message_blocks: From a9a382b97c025a680e22d2d88792e0c187e685cf Mon Sep 17 00:00:00 2001 From: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:01:23 -0700 Subject: [PATCH 06/19] Handles JSONDecodeError exception when parsing GenAI JSON string (#5359) --- .../plugins/dispatch_slack/case/messages.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/case/messages.py b/src/dispatch/plugins/dispatch_slack/case/messages.py index 8d3bb8dc6e70..88c85a0756db 100644 --- a/src/dispatch/plugins/dispatch_slack/case/messages.py +++ b/src/dispatch/plugins/dispatch_slack/case/messages.py @@ -462,12 +462,18 @@ def create_genai_signal_analysis_message( """ ) - message = json.loads( - response["choices"][0]["message"]["content"] - .replace("```json", "") - .replace("```", "") - .strip() - ) + + try: + message = json.loads( + response["choices"][0]["message"]["content"] + .replace("```json", "") + .replace("```", "") + .strip() + ) + except json.JSONDecodeError as e: + message = "Unable to generate GenAI signal analysis. Error decoding response from the artificial-intelligence plugin." + log.warning(f"{message} Error: {e}") + return message, create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) # we check if the response is empty if not message: From d648a960c9ad21bfa6172dfb95bafb338438570e Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:54:20 -0700 Subject: [PATCH 07/19] Fixing linting errors (#5360) --- .../dispatch/src/components/GenaiAnalysisDisplay.vue | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/dispatch/static/dispatch/src/components/GenaiAnalysisDisplay.vue b/src/dispatch/static/dispatch/src/components/GenaiAnalysisDisplay.vue index 5e34c54d1474..34bf59cc0f72 100644 --- a/src/dispatch/static/dispatch/src/components/GenaiAnalysisDisplay.vue +++ b/src/dispatch/static/dispatch/src/components/GenaiAnalysisDisplay.vue @@ -8,13 +8,9 @@
- +
- +
From 1f34f83be56fff45db1afd84f290f6b22a887d0e Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:14:28 -0700 Subject: [PATCH 08/19] feat(case): adding case feedback (#5356) --- src/dispatch/case/flows.py | 6 + src/dispatch/case/messaging.py | 42 +++++++ src/dispatch/case/models.py | 2 + src/dispatch/conversation/enums.py | 1 + .../versions/2024-10-16_3c49f62d7914.py | 63 ++++++++++ src/dispatch/database/service.py | 7 +- src/dispatch/feedback/incident/models.py | 19 +-- src/dispatch/feedback/incident/service.py | 23 +++- src/dispatch/messaging/strings.py | 19 +++ .../plugins/dispatch_slack/feedback/enums.py | 17 +++ .../dispatch_slack/feedback/interactive.py | 114 +++++++++++++++++- .../dispatch/src/feedback/incident/Table.vue | 21 +++- .../feedback/incident/TableFilterDialog.vue | 10 +- .../dispatch/src/feedback/incident/store.js | 2 + .../static/dispatch/src/router/config.js | 2 +- 15 files changed, 325 insertions(+), 23 deletions(-) create mode 100644 src/dispatch/database/revisions/tenant/versions/2024-10-16_3c49f62d7914.py diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index b802e841632d..21aa8d517b4f 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -37,6 +37,7 @@ from .messaging import ( send_case_created_notifications, send_case_update_notifications, + send_case_rating_feedback_message, ) from .models import Case, CaseStatus @@ -498,6 +499,11 @@ def case_closed_status_flow(case: Case, db_session=None): for document in case.documents: document_flows.mark_document_as_readonly(document=document, db_session=db_session) + if case.dedicated_channel: + # we send a direct message to all participants asking them + # to rate and provide feedback about the case + send_case_rating_feedback_message(case, db_session) + def reactivate_case_participants(case: Case, db_session: Session): """Reactivates all case participants.""" diff --git a/src/dispatch/case/messaging.py b/src/dispatch/case/messaging.py index a030b6903eae..d223a03d37b7 100644 --- a/src/dispatch/case/messaging.py +++ b/src/dispatch/case/messaging.py @@ -24,6 +24,7 @@ CASE_TYPE_CHANGE, CASE_SEVERITY_CHANGE, CASE_PRIORITY_CHANGE, + CASE_CLOSED_RATING_FEEDBACK_NOTIFICATION, MessageType, ) from dispatch.config import DISPATCH_UI_URL @@ -330,3 +331,44 @@ def send_case_welcome_participant_message( ) log.debug(f"Welcome ephemeral message sent to {participant_email}.") + + +def send_case_rating_feedback_message(case: Case, db_session: Session): + """ + Sends a direct message to all case participants asking + them to rate and provide feedback about the case. + """ + notification_text = "Case Rating and Feedback" + notification_template = CASE_CLOSED_RATING_FEEDBACK_NOTIFICATION + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning("Case rating and feedback message not sent, no conversation plugin enabled.") + return + + items = [ + { + "case_id": case.id, + "organization_slug": case.project.organization.slug, + "name": case.name, + "title": case.title, + "ticket_weblink": case.ticket.weblink, + } + ] + + for participant in case.participants: + try: + plugin.instance.send_direct( + participant.individual.email, + notification_text, + notification_template, + MessageType.case_rating_feedback, + items=items, + ) + except Exception as e: + # if one fails we don't want all to fail + log.exception(e) + + log.debug("Case rating and feedback message sent to all participants.") diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py index 0559664e6312..9065db786413 100644 --- a/src/dispatch/case/models.py +++ b/src/dispatch/case/models.py @@ -128,6 +128,8 @@ class Case(Base, TimeStampMixin, ProjectMixin): events = relationship("Event", backref="case", cascade="all, delete-orphan") + feedback = relationship("Feedback", backref="case", cascade="all, delete-orphan") + groups = relationship( "Group", backref="case", cascade="all, delete-orphan", foreign_keys=[Group.case_id] ) diff --git a/src/dispatch/conversation/enums.py b/src/dispatch/conversation/enums.py index 5467a17c30be..643d4767bd7a 100644 --- a/src/dispatch/conversation/enums.py +++ b/src/dispatch/conversation/enums.py @@ -14,6 +14,7 @@ class ConversationCommands(DispatchEnum): class ConversationButtonActions(DispatchEnum): feedback_notification_provide = "feedback-notification-provide" + case_feedback_notification_provide = "case-feedback-notification-provide" invite_user = "invite-user" invite_user_case = "invite-user-case" monitor_link = "monitor-link" diff --git a/src/dispatch/database/revisions/tenant/versions/2024-10-16_3c49f62d7914.py b/src/dispatch/database/revisions/tenant/versions/2024-10-16_3c49f62d7914.py new file mode 100644 index 000000000000..caad8de0efa5 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-10-16_3c49f62d7914.py @@ -0,0 +1,63 @@ +"""Adds case_id and project_id to feedback + +Revision ID: 3c49f62d7914 +Revises: b8c1a8a4d957 +Create Date: 2024-10-16 15:21:17.120891 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import Session +from dispatch.feedback.incident.models import Feedback + +# revision identifiers, used by Alembic. +revision = "3c49f62d7914" +down_revision = "b8c1a8a4d957" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("feedback", sa.Column("case_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "assoc_feedback_case_id_fkey", + "feedback", + "case", + ["case_id"], + ["id"], + ondelete="CASCADE", + ) + op.add_column("feedback", sa.Column("project_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "assoc_feedback_project_id_fkey", + "feedback", + "project", + ["project_id"], + ["id"], + ondelete="CASCADE", + ) + + bind = op.get_bind() + session = Session(bind=bind) + + instances = session.query(Feedback).all() + + for instance in instances: + if instance.incident: + instance.project_id = instance.incident.project_id + elif instance.case: + instance.project_id = instance.case.project_id + + session.commit() + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("assoc_feedback_case_id_fkey", "feedback", type_="foreignkey") + op.drop_column("feedback", "case_id") + op.drop_constraint("assoc_feedback_project_id_fkey", "feedback", type_="foreignkey") + op.drop_column("feedback", "project_id") + # ### end Alembic commands ### diff --git a/src/dispatch/database/service.py b/src/dispatch/database/service.py index 1d7728358df8..209a3735b86f 100644 --- a/src/dispatch/database/service.py +++ b/src/dispatch/database/service.py @@ -185,9 +185,8 @@ def build_filters(filter_spec): if not _is_iterable_filter(fn_args): raise BadFilterFormat( - "`{}` value must be an iterable across the function " "arguments".format( - boolean_function.key - ) + "`{}` value must be an iterable across the function " + "arguments".format(boolean_function.key) ) if boolean_function.only_one_arg and len(fn_args) != 1: raise BadFilterFormat( @@ -347,8 +346,8 @@ def apply_filter_specific_joins(model: Base, filter_spec: dict, query: orm.query # this is required because by default sqlalchemy-filter's auto-join # knows nothing about how to join many-many relationships. model_map = { - (Feedback, "Project"): (Incident, False), (Feedback, "Incident"): (Incident, False), + (Feedback, "Case"): (Case, False), (Task, "Project"): (Incident, False), (Task, "Incident"): (Incident, False), (Task, "IncidentPriority"): (Incident, False), diff --git a/src/dispatch/feedback/incident/models.py b/src/dispatch/feedback/incident/models.py index ea320894e424..a6389093c2f7 100644 --- a/src/dispatch/feedback/incident/models.py +++ b/src/dispatch/feedback/incident/models.py @@ -3,24 +3,32 @@ from typing import Optional, List from sqlalchemy import Column, Integer, ForeignKey -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy_utils import TSVectorType from dispatch.database.core import Base from dispatch.incident.models import IncidentReadMinimal -from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey, Pagination +from dispatch.models import ( + DispatchBase, + TimeStampMixin, + FeedbackMixin, + PrimaryKey, + Pagination, + ProjectMixin, +) from dispatch.participant.models import ParticipantRead from dispatch.project.models import ProjectRead +from dispatch.case.models import CaseReadMinimal from .enums import FeedbackRating -class Feedback(TimeStampMixin, FeedbackMixin, Base): +class Feedback(TimeStampMixin, FeedbackMixin, ProjectMixin, Base): # Columns id = Column(Integer, primary_key=True) # Relationships incident_id = Column(Integer, ForeignKey("incident.id", ondelete="CASCADE")) + case_id = Column(Integer, ForeignKey("case.id", ondelete="CASCADE")) participant_id = Column(Integer, ForeignKey("participant.id")) search_vector = Column( @@ -31,10 +39,6 @@ class Feedback(TimeStampMixin, FeedbackMixin, Base): ) ) - @hybrid_property - def project(self): - return self.incident.project - # Pydantic models class FeedbackBase(DispatchBase): @@ -42,6 +46,7 @@ class FeedbackBase(DispatchBase): rating: FeedbackRating = FeedbackRating.very_satisfied feedback: Optional[str] = Field(None, nullable=True) incident: Optional[IncidentReadMinimal] + case: Optional[CaseReadMinimal] participant: Optional[ParticipantRead] diff --git a/src/dispatch/feedback/incident/service.py b/src/dispatch/feedback/incident/service.py index 48594f002251..fd80be90909a 100644 --- a/src/dispatch/feedback/incident/service.py +++ b/src/dispatch/feedback/incident/service.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from dispatch.incident import service as incident_service +from dispatch.case import service as case_service from dispatch.incident.models import Incident from dispatch.project.models import Project @@ -34,13 +35,25 @@ def get_all_last_x_hours_by_project_id( def create(*, db_session, feedback_in: FeedbackCreate) -> Feedback: """Creates a new piece of feedback.""" - incident = incident_service.get( - db_session=db_session, - incident_id=feedback_in.incident.id, - ) + if feedback_in.incident: + incident = incident_service.get( + db_session=db_session, + incident_id=feedback_in.incident.id, + ) + project = incident.project + case = None + else: + case = case_service.get( + db_session=db_session, + case_id=feedback_in.case.id, + ) + project = case.project + incident = None feedback = Feedback( - **feedback_in.dict(exclude={"incident"}), + **feedback_in.dict(exclude={"incident", "case", "project"}), incident=incident, + case=case, + project=project, ) db_session.add(feedback) db_session.commit() diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 8908b4ad2563..70e0353d979d 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -42,6 +42,7 @@ class MessageType(DispatchEnum): case_status_reminder = "case-status-reminder" service_feedback = "service-feedback" task_add_to_incident = "task-add-to-incident" + case_rating_feedback = "case-rating-feedback" INCIDENT_STATUS_DESCRIPTIONS = { @@ -373,6 +374,9 @@ class MessageType(DispatchEnum): INCIDENT_CLOSED_RATING_FEEDBACK_DESCRIPTION = """ Thanks for participating in the {{name}} ("{{title}}") incident. We would appreciate if you could rate your experience and provide feedback.""" +CASE_CLOSED_RATING_FEEDBACK_DESCRIPTION = """ +Thanks for participating in the {{name}} ("{{title}}") case. We would appreciate if you could rate your experience and provide feedback.""" + INCIDENT_MANAGEMENT_HELP_TIPS_MESSAGE_DESCRIPTION = """ Hey, I see you're the Incident Commander for <{{conversation_weblink}}|{{name}}> ("{{title}}"). Here are a few things to consider when managing the incident: \n • Keep the incident and its status up to date using the Slack `{{update_command}}` command. @@ -971,6 +975,21 @@ class MessageType(DispatchEnum): } ] +CASE_CLOSED_RATING_FEEDBACK_NOTIFICATION = [ + { + "title": "{{name}} Case - Rating and Feedback", + "title_link": "{{ticket_weblink}}", + "text": CASE_CLOSED_RATING_FEEDBACK_DESCRIPTION, + "buttons": [ + { + "button_text": "Provide Feedback", + "button_value": "{{organization_slug}}-{{case_id}}", + "button_action": ConversationButtonActions.case_feedback_notification_provide, + } + ], + } +] + INCIDENT_FEEDBACK_DAILY_REPORT = [ {"title": "Incident", "text": "{{ name }}"}, {"title": "Incident Title", "text": "{{ title }}"}, diff --git a/src/dispatch/plugins/dispatch_slack/feedback/enums.py b/src/dispatch/plugins/dispatch_slack/feedback/enums.py index 5fc94ba386d4..162e8f839480 100644 --- a/src/dispatch/plugins/dispatch_slack/feedback/enums.py +++ b/src/dispatch/plugins/dispatch_slack/feedback/enums.py @@ -8,17 +8,34 @@ class IncidentFeedbackNotificationBlockIds(DispatchEnum): anonymous_checkbox = "incident-feedback-notification-anonymous-checkbox" +class CaseFeedbackNotificationBlockIds(DispatchEnum): + feedback_input = "case-feedback-notification-feedback-input" + rating_select = "case-feedback-notification-rating-select" + anonymous_checkbox = "case-feedback-notification-anonymous-checkbox" + + class IncidentFeedbackNotificationActionIds(DispatchEnum): feedback_input = "incident-feedback-notification-feedback-input" rating_select = "incident-feedback-notification-rating-select" anonymous_checkbox = "incident-feedback-notification-anonymous-checkbox" +class CaseFeedbackNotificationActionIds(DispatchEnum): + feedback_input = "case-feedback-notification-feedback-input" + rating_select = "case-feedback-notification-rating-select" + anonymous_checkbox = "case-feedback-notification-anonymous-checkbox" + + class IncidentFeedbackNotificationActions(DispatchEnum): submit = "incident-feedback-notification-submit" provide = ConversationButtonActions.feedback_notification_provide +class CaseFeedbackNotificationActions(DispatchEnum): + submit = "case-feedback-notification-submit" + provide = ConversationButtonActions.case_feedback_notification_provide + + class ServiceFeedbackNotificationBlockIds(DispatchEnum): anonymous_checkbox = "service-feedback-notification-anonymous-checkbox" feedback_input = "service-feedback-notification-feedback-input" diff --git a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py index a9d1df5eadbc..258fc6fb7840 100644 --- a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py @@ -17,13 +17,14 @@ from datetime import datetime from dispatch.auth.models import DispatchUser -from dispatch.feedback.incident import service as incident_feedback_service +from dispatch.feedback.incident import service as subject_feedback_service from dispatch.feedback.incident.enums import FeedbackRating from dispatch.feedback.incident.models import FeedbackCreate from dispatch.feedback.service import service as feedback_service from dispatch.individual import service as individual_service from dispatch.feedback.service.models import ServiceFeedbackRating, ServiceFeedbackCreate from dispatch.incident import service as incident_service +from dispatch.case import service as case_service from dispatch.participant import service as participant_service from dispatch.feedback.service.reminder import service as reminder_service from dispatch.plugin import service as plugin_service @@ -45,6 +46,9 @@ ServiceFeedbackNotificationActionIds, ServiceFeedbackNotificationActions, ServiceFeedbackNotificationBlockIds, + CaseFeedbackNotificationActionIds, + CaseFeedbackNotificationActions, + CaseFeedbackNotificationBlockIds, ) from dispatch.messaging.strings import ( ONCALL_SHIFT_FEEDBACK_RECEIVED, @@ -195,7 +199,7 @@ def handle_incident_feedback_submission_event( feedback_in = FeedbackCreate( rating=rating, feedback=feedback, project=incident.project, incident=incident ) - feedback = incident_feedback_service.create(db_session=db_session, feedback_in=feedback_in) + feedback = subject_feedback_service.create(db_session=db_session, feedback_in=feedback_in) incident.feedback.append(feedback) # we only really care if this exists, if it doesn't then flag is false @@ -467,3 +471,109 @@ def handle_oncall_shift_feedback_submission_event( ) except Exception as e: log.exception(e) + + +@app.action( + CaseFeedbackNotificationActions.provide, + middleware=[button_context_middleware, db_middleware], +) +def handle_case_feedback_direct_message_button_click( + ack: Ack, + body: dict, + client: WebClient, + respond: Respond, + db_session: Session, + context: BoltContext, +): + """Handles the feedback button in the feedback direct message.""" + ack() + case = case_service.get(db_session=db_session, case_id=context["subject"].id) + + if not case: + message = "Sorry, you cannot submit feedback about this case. The case does not exist." + respond(message=message, ephemeral=True) + return + + blocks = [ + Context( + elements=[MarkdownText(text="Use this form to rate your experience about the case.")] + ), + rating_select( + action_id=CaseFeedbackNotificationActionIds.rating_select, + block_id=CaseFeedbackNotificationBlockIds.rating_select, + ), + feedback_input( + action_id=CaseFeedbackNotificationActionIds.feedback_input, + block_id=CaseFeedbackNotificationBlockIds.feedback_input, + ), + anonymous_checkbox( + action_id=CaseFeedbackNotificationActionIds.anonymous_checkbox, + block_id=CaseFeedbackNotificationBlockIds.anonymous_checkbox, + ), + ] + + modal = Modal( + title="Case Feedback", + blocks=blocks, + submit="Submit", + close="Cancel", + callback_id=CaseFeedbackNotificationActions.submit, + private_metadata=context["subject"].json(), + ).build() + + client.views_open(trigger_id=body["trigger_id"], view=modal) + + +def ack_case_feedback_submission_event(ack: Ack) -> None: + """Handles the feedback submission event acknowledgement.""" + modal = Modal( + title="Case Feedback", close="Close", blocks=[Section(text="Submitting feedback...")] + ).build() + ack(response_action="update", view=modal) + + +@app.view( + CaseFeedbackNotificationActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +def handle_case_feedback_submission_event( + ack: Ack, + body: dict, + context: BoltContext, + user: DispatchUser, + client: WebClient, + db_session: Session, + form_data: dict, +): + # TODO: handle multiple organizations during submission + ack_case_feedback_submission_event(ack=ack) + case = case_service.get(db_session=db_session, case_id=context["subject"].id) + + feedback = form_data.get(CaseFeedbackNotificationBlockIds.feedback_input, "") + rating = form_data.get(CaseFeedbackNotificationBlockIds.rating_select, {}).get("value") + + feedback_in = FeedbackCreate(rating=rating, feedback=feedback, project=case.project, case=case) + feedback = subject_feedback_service.create(db_session=db_session, feedback_in=feedback_in) + case.feedback.append(feedback) + + # we only really care if this exists, if it doesn't then flag is false + if not form_data.get(CaseFeedbackNotificationBlockIds.anonymous_checkbox): + participant = participant_service.get_by_case_id_and_email( + db_session=db_session, case_id=context["subject"].id, email=user.email + ) + participant.feedback.append(feedback) + db_session.add(participant) + + db_session.add(case) + db_session.commit() + + modal = Modal( + title="Case Feedback", + close="Close", + blocks=[Section(text="Submitting feedback... Success!")], + ).build() + + client.views_update( + view_id=body["view"]["id"], + view=modal, + ) diff --git a/src/dispatch/static/dispatch/src/feedback/incident/Table.vue b/src/dispatch/static/dispatch/src/feedback/incident/Table.vue index 894ab2fcf007..d6b4184a063c 100644 --- a/src/dispatch/static/dispatch/src/feedback/incident/Table.vue +++ b/src/dispatch/static/dispatch/src/feedback/incident/Table.vue @@ -3,7 +3,7 @@ -
Incident feedback
+
Incident and Case feedback
@@ -49,6 +49,18 @@ {{ item.project.name }} + + +