From 900b3321de061d3f98ba5560671d7aacc1ba78a7 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Thu, 17 Oct 2024 09:54:16 -0700 Subject: [PATCH 1/7] Adding case feedback --- 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 | 30 +++++ src/dispatch/feedback/incident/models.py | 5 +- src/dispatch/feedback/incident/service.py | 2 +- 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 | 19 ++- .../dispatch/src/feedback/incident/store.js | 1 + .../static/dispatch/src/router/config.js | 2 +- 13 files changed, 252 insertions(+), 8 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..c3545e233deb --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-10-16_3c49f62d7914.py @@ -0,0 +1,30 @@ +"""Adds case_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 + +# 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(None, "feedback", "case", ["case_id"], ["id"], ondelete="CASCADE") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "feedback", type_="foreignkey") + op.drop_column("feedback", "case_id") + # ### end Alembic commands ### diff --git a/src/dispatch/feedback/incident/models.py b/src/dispatch/feedback/incident/models.py index ea320894e424..88b73da22768 100644 --- a/src/dispatch/feedback/incident/models.py +++ b/src/dispatch/feedback/incident/models.py @@ -11,6 +11,7 @@ from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey, Pagination from dispatch.participant.models import ParticipantRead from dispatch.project.models import ProjectRead +from dispatch.case.models import CaseReadMinimal from .enums import FeedbackRating @@ -21,6 +22,7 @@ class Feedback(TimeStampMixin, FeedbackMixin, Base): # 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( @@ -33,7 +35,7 @@ class Feedback(TimeStampMixin, FeedbackMixin, Base): @hybrid_property def project(self): - return self.incident.project + return self.incident.project if self.incident else self.case.project # Pydantic models @@ -42,6 +44,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..7e400852241d 100644 --- a/src/dispatch/feedback/incident/service.py +++ b/src/dispatch/feedback/incident/service.py @@ -15,7 +15,7 @@ def get(*, db_session, feedback_id: int) -> Optional[Feedback]: def get_all(*, db_session): """Gets all pieces of feedback.""" - return db_session.query(Feedback) + return db_session.query(Feedback).filter(Feedback.incident_id.isnot(None)).all() def get_all_last_x_hours_by_project_id( diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 8908b4ad2563..5b985b7972ee 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}}-{{incident_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..4854c0ea38ff 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 }} + + +