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 }} + + +