diff --git a/src/dispatch/database/revisions/tenant/versions/2023-09-08_0560fab4537f.py b/src/dispatch/database/revisions/tenant/versions/2023-09-08_0560fab4537f.py new file mode 100644 index 000000000000..767fc54954fd --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2023-09-08_0560fab4537f.py @@ -0,0 +1,47 @@ +"""Adds reminders to oncall shift feedback + +Revision ID: 0560fab4537f +Revises: 0356472ea980 +Create Date: 2023-09-08 17:13:57.903367 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0560fab4537f" +down_revision = "0356472ea980" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "service_feedback_reminder", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reminder_at", sa.DateTime(), nullable=True), + sa.Column("schedule_id", sa.String(), nullable=True), + sa.Column("schedule_name", sa.String(), nullable=True), + sa.Column("shift_end_at", sa.DateTime(), nullable=True), + sa.Column("individual_contact_id", sa.Integer(), nullable=True), + sa.Column("project_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["individual_contact_id"], + ["individual_contact.id"], + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["project.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("service_feedback_reminder") + # ### end Alembic commands ### diff --git a/src/dispatch/feedback/service/messaging.py b/src/dispatch/feedback/service/messaging.py index a007d27f7e06..bdbdfee4ee31 100644 --- a/src/dispatch/feedback/service/messaging.py +++ b/src/dispatch/feedback/service/messaging.py @@ -1,15 +1,19 @@ import logging +from datetime import datetime, timedelta +from typing import Optional from sqlalchemy.orm import Session from dispatch.individual.models import IndividualContact from dispatch.messaging.strings import ( ONCALL_SHIFT_FEEDBACK_NOTIFICATION, + ONCALL_SHIFT_FEEDBACK_NOTIFICATION_REMINDER, MessageType, ) from dispatch.plugin import service as plugin_service from dispatch.project.models import Project - +from .reminder.models import ServiceFeedbackReminder, ServiceFeedbackReminderUpdate +from .reminder import service as reminder_service log = logging.getLogger(__name__) @@ -21,6 +25,7 @@ def send_oncall_shift_feedback_message( schedule_id: str, shift_end_at: str, schedule_name: str, + reminder: Optional[ServiceFeedbackReminder] = None, db_session: Session, ): """ @@ -39,6 +44,32 @@ def send_oncall_shift_feedback_message( ) return + if reminder: + # update reminder with 23 hours from now + reminder = reminder_service.update( + db_session=db_session, + reminder=reminder, + reminder_in=ServiceFeedbackReminderUpdate( + id=reminder.id, + reminder_at=datetime.utcnow() + timedelta(hours=23), + ), + ) + notification_template = ONCALL_SHIFT_FEEDBACK_NOTIFICATION_REMINDER + else: + # create reminder and pass to plugin + reminder = reminder_service.create( + db_session=db_session, + reminder_in=ServiceFeedbackReminder( + reminder_at=datetime.utcnow() + timedelta(hours=23), + individual_contact_id=individual.id, + project_id=project.id, + schedule_id=schedule_id, + schedule_name=schedule_name, + shift_end_at=shift_end_at, + ), + ) + + shift_end_clean = shift_end_at.replace("T", " ").replace("Z", "") items = [ { "individual_name": individual.name, @@ -46,7 +77,8 @@ def send_oncall_shift_feedback_message( "oncall_service_name": schedule_name, "organization_slug": project.organization.slug, "project_id": project.id, - "shift_end_at": shift_end_at, + "shift_end_at": shift_end_clean, + "reminder_id": reminder.id, } ] diff --git a/src/dispatch/feedback/service/reminder/__init__.py b/src/dispatch/feedback/service/reminder/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/feedback/service/reminder/models.py b/src/dispatch/feedback/service/reminder/models.py new file mode 100644 index 000000000000..524b9319a021 --- /dev/null +++ b/src/dispatch/feedback/service/reminder/models.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, Integer, ForeignKey, DateTime, String + +from dispatch.database.core import Base +from dispatch.individual.models import IndividualContactRead +from dispatch.models import DispatchBase, TimeStampMixin, PrimaryKey +from dispatch.project.models import ProjectRead + + +class ServiceFeedbackReminder(TimeStampMixin, Base): + # Columns + id = Column(Integer, primary_key=True) + reminder_at = Column(DateTime) + schedule_id = Column(String) + schedule_name = Column(String) + shift_end_at = Column(DateTime) + + # Relationships + individual_contact_id = Column(Integer, ForeignKey("individual_contact.id")) + project_id = Column(Integer, ForeignKey("project.id")) + + +# Pydantic models +class ServiceFeedbackReminderBase(DispatchBase): + reminder_at: Optional[datetime] + individual: Optional[IndividualContactRead] + project: Optional[ProjectRead] + schedule_id: Optional[str] + schedule_name: Optional[str] + shift_end_at: Optional[datetime] + + +class ServiceFeedbackReminderCreate(ServiceFeedbackReminderBase): + pass + + +class ServiceFeedbackReminderUpdate(ServiceFeedbackReminderBase): + id: PrimaryKey = None + reminder_at: Optional[datetime] + + +class ServiceFeedbackReminderRead(ServiceFeedbackReminderBase): + id: PrimaryKey diff --git a/src/dispatch/feedback/service/reminder/service.py b/src/dispatch/feedback/service/reminder/service.py new file mode 100644 index 000000000000..ae264e1d403d --- /dev/null +++ b/src/dispatch/feedback/service/reminder/service.py @@ -0,0 +1,59 @@ +from typing import List, Optional +from datetime import datetime, timedelta + +from .models import ( + ServiceFeedbackReminder, + ServiceFeedbackReminderCreate, + ServiceFeedbackReminderUpdate, +) +from dispatch.individual.models import IndividualContact +from dispatch.project.models import Project + + +def get_all_expired_reminders_by_project_id( + *, db_session, project_id: int +) -> List[Optional[ServiceFeedbackReminder]]: + """Returns all expired reminders by project id.""" + return ( + db_session.query(ServiceFeedbackReminder) + .join(IndividualContact) + .join(Project) + .filter(Project.id == project_id) + .filter(datetime.utcnow() >= ServiceFeedbackReminder.reminder_at - timedelta(minutes=1)) + .all() + ) + + +def create(*, db_session, reminder_in: ServiceFeedbackReminderCreate) -> ServiceFeedbackReminder: + """Creates a new service feedback reminder.""" + reminder = ServiceFeedbackReminder(**reminder_in.dict()) + + db_session.add(reminder_in) + db_session.commit() + return reminder + + +def update( + *, db_session, reminder: ServiceFeedbackReminder, reminder_in: ServiceFeedbackReminderUpdate +) -> ServiceFeedbackReminder: + """Updates a service feedback reminder.""" + reminder_data = reminder.dict() + update_data = reminder_in.dict(skip_defaults=True) + + for field in reminder_data: + if field in update_data: + setattr(reminder, field, update_data[field]) + + db_session.commit() + return reminder + + +def delete(*, db_session, reminder_id: int): + """Deletes a service feedback reminder.""" + reminder = ( + db_session.query(ServiceFeedbackReminder) + .filter(ServiceFeedbackReminder.id == reminder_id) + .one_or_none() + ) + db_session.delete(reminder) + db_session.commit() diff --git a/src/dispatch/feedback/service/scheduled.py b/src/dispatch/feedback/service/scheduled.py index a33784f4da30..6f5968659559 100644 --- a/src/dispatch/feedback/service/scheduled.py +++ b/src/dispatch/feedback/service/scheduled.py @@ -9,6 +9,7 @@ from dispatch.project.models import Project from dispatch.scheduler import scheduler from dispatch.service import service as service_service +from .reminder import service as reminder_service from .messaging import send_oncall_shift_feedback_message @@ -28,6 +29,7 @@ @scheduled_project_task def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project): oncall_shift_feedback(db_session=db_session, project=project) + find_expired_reminders_and_send(db_session=db_session, project=project) @scheduler.add(every(1).day.at("06:00"), name="oncall-shift-feedback-emea") @@ -35,6 +37,26 @@ def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project): @scheduled_project_task def oncall_shift_feedback_emea(db_session: SessionLocal, project: Project): oncall_shift_feedback(db_session=db_session, project=project) + find_expired_reminders_and_send(db_session=db_session, project=project) + + +def find_expired_reminders_and_send(*, db_session: SessionLocal, project: Project): + reminders = reminder_service.get_all_expired_reminders_by_project_id( + db_session=db_session, project_id=project.id + ) + for reminder in reminders: + individual = individual_service.get( + db_session=db_session, individual_contact_id=reminder.individual_contact_id + ) + send_oncall_shift_feedback_message( + project=project, + individual=individual, + schedule_id=reminder.schedule_id, + shift_end_at=str(reminder.shift_end_at), + schedule_name=reminder.schedule_name, + reminder=reminder, + db_session=db_session, + ) def find_schedule_and_send( diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index f327a280e172..0facbef81ce4 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -321,7 +321,10 @@ class MessageType(DispatchEnum): """ ONCALL_SHIFT_FEEDBACK_DESCRIPTION = """ -Hi {{ individual_name }}, it appears that your {{ oncall_service_name }} shift has completed. To help us understand the impact on our responders, we would appreciate your feedback.""" +Hi {{ individual_name }}, it appears that your {{ oncall_service_name }} shift recently completed on {{ shift_end_at }} UTC. To help us understand the impact on our responders, we would appreciate your feedback.""" + +ONCALL_SHIFT_FEEDBACK_RECEIVED_DESCRIPTION = """ +We received your feedback for your shift that ended {{ shift_end_at }} UTC. Thank you!""" INCIDENT_STATUS_CHANGE_DESCRIPTION = """ The incident status has been changed from {{ incident_status_old }} to {{ incident_status_new }}.""".replace( @@ -763,17 +766,34 @@ class MessageType(DispatchEnum): } ] +ONCALL_SHIFT_FEEDBACK_BUTTONS = [ + { + "button_text": "Provide Feedback", + "button_value": "{{organization_slug}}|{{project_id}}|{{oncall_schedule_id}}|{{shift_end_at}}|{{reminder_id}}", + "button_action": ConversationButtonActions.service_feedback, + } +] + ONCALL_SHIFT_FEEDBACK_NOTIFICATION = [ { "title": "Oncall Shift Feedback", "text": ONCALL_SHIFT_FEEDBACK_DESCRIPTION, - "buttons": [ - { - "button_text": "Provide Feedback", - "button_value": "{{organization_slug}}|{{project_id}}|{{oncall_schedule_id}}|{{shift_end_at}}", - "button_action": ConversationButtonActions.service_feedback, - } - ], + "buttons": ONCALL_SHIFT_FEEDBACK_BUTTONS, + } +] + +ONCALL_SHIFT_FEEDBACK_NOTIFICATION_REMINDER = [ + { + "title": "Oncall Shift Feedback - REMINDER", + "text": ONCALL_SHIFT_FEEDBACK_DESCRIPTION, + "buttons": ONCALL_SHIFT_FEEDBACK_BUTTONS, + } +] + +ONCALL_SHIFT_FEEDBACK_RECEIVED = [ + { + "title": "Oncall Shift Feedback - RECEIVED", + "text": ONCALL_SHIFT_FEEDBACK_RECEIVED_DESCRIPTION, } ] diff --git a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py index 0cb708351366..fadaa04a4f22 100644 --- a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py @@ -23,6 +23,8 @@ from dispatch.feedback.service.models import ServiceFeedbackRating from dispatch.incident import service as incident_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 from dispatch.plugins.dispatch_slack.bolt import app from dispatch.plugins.dispatch_slack.fields import static_select_block from dispatch.plugins.dispatch_slack.middleware import ( @@ -41,6 +43,10 @@ ServiceFeedbackNotificationActions, ServiceFeedbackNotificationBlockIds, ) +from dispatch.messaging.strings import ( + ONCALL_SHIFT_FEEDBACK_RECEIVED, + MessageType, +) log = logging.getLogger(__file__) @@ -378,11 +384,20 @@ def handle_oncall_shift_feedback_submission_event( feedback = form_data.get(ServiceFeedbackNotificationBlockIds.feedback_input) rating = form_data.get(ServiceFeedbackNotificationBlockIds.rating_select, {}).get("value") - # metadata is organization_slug|project_id|schedule_id|shift_end_at + # metadata is organization_slug|project_id|schedule_id|shift_end_at|reminder_id metadata = body["view"]["private_metadata"].split("|") project_id = metadata[1] schedule_id = metadata[2] - shift_end_at = datetime.strptime(metadata[3], "%Y-%m-%dT%H:%M:%SZ") + shift_end_raw = metadata[3] + shift_end_at = ( + datetime.strptime(shift_end_raw, "%Y-%m-%dT%H:%M:%SZ") + if "T" in shift_end_raw + else datetime.strptime(shift_end_raw, "%Y-%m-%d %H:%M:%S") + ) + # if there's a reminder id, delete the reminder + if len(metadata) > 4: + reminder_id = metadata[4] + reminder_service.delete(db_session=db_session, reminder_id=reminder_id) individual = ( None @@ -416,3 +431,26 @@ def handle_oncall_shift_feedback_submission_event( view_id=body["view"]["id"], view=modal, ) + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=project_id, plugin_type="conversation" + ) + + if plugin: + notification_text = "Oncall Shift Feedback Received" + notification_template = ONCALL_SHIFT_FEEDBACK_RECEIVED + items = [ + { + "shift_end_at": shift_end_at, + } + ] + try: + plugin.instance.send_direct( + individual.email, + notification_text, + notification_template, + MessageType.service_feedback, + items=items, + ) + except Exception as e: + log.exception(e)