diff --git a/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py b/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py new file mode 100644 index 000000000000..4c6a119b3e17 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py @@ -0,0 +1,35 @@ +"""adds snooze extension oncall service to project + +Revision ID: bccbf255d6d1 +Revises: 92a359040b8e +Create Date: 2025-03-28 13:12:07.514337 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bccbf255d6d1" +down_revision = "92a359040b8e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "project", sa.Column("snooze_extension_oncall_service_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + None, "project", "service", ["snooze_extension_oncall_service_id"], ["id"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "project", type_="foreignkey") + op.drop_column("project", "snooze_extension_oncall_service_id") + # ### end Alembic commands ### diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index d7a3c1728c30..403b5e8a9533 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -74,6 +74,7 @@ case_type_select, description_input, entity_select, + extension_request_checkbox, incident_priority_select, incident_type_select, project_select, @@ -106,6 +107,7 @@ from dispatch.project import service as project_service from dispatch.search.utils import create_filter_expression from dispatch.service import flows as service_flows +from dispatch.service.models import Service from dispatch.signal import service as signal_service from dispatch.signal.enums import SignalEngagementStatus from dispatch.signal.models import ( @@ -733,6 +735,7 @@ def snooze_button_click( title_input(placeholder="A name for your snooze filter."), description_input(placeholder="Provide a description for your snooze filter."), relative_date_picker_input(label="Expiration"), + extension_request_checkbox(), ] # not all signals will have entities and slack doesn't like empty selects @@ -903,11 +906,16 @@ def handle_snooze_submission_event( ) mfa_enabled = True if mfa_plugin else False + form_data: FormData = context["subject"].form_data + extension_request_data = form_data.get(DefaultBlockIds.extension_request_checkbox) + extension_request_value = extension_request_data[0].value if extension_request_data else None + extension_requested = True if extension_request_value == "Yes" else False + def _create_snooze_filter( db_session: Session, subject: SubjectMetadata, user: DispatchUser, - ) -> None: + ) -> SignalFilter: form_data: FormData = subject.form_data # Get the existing filters for the signal signal = signal_service.get(db_session=db_session, signal_id=subject.id) @@ -1005,6 +1013,8 @@ def _create_snooze_filter( signal=signal, new_filter=new_filter, thread_ts=thread_id, + extension_requested=extension_requested, + oncall_service=new_filter.project.snooze_extension_oncall_service, ) send_success_modal( client=client, @@ -1044,6 +1054,8 @@ def _create_snooze_filter( signal=signal, new_filter=new_filter, thread_ts=thread_id, + extension_requested=extension_requested, + oncall_service=new_filter.project.snooze_extension_oncall_service, ) send_success_modal( client=client, @@ -1073,6 +1085,24 @@ def _create_snooze_filter( ) +def get_user_id_from_oncall_service( + client: WebClient, + db_session: Session, + oncall_service: Service | None, +) -> str | None: + if not oncall_service: + return None + + oncall_email = service_flows.resolve_oncall(service=oncall_service, db_session=db_session) + if oncall_email: + # Get the Slack user ID for the current oncall + try: + return client.users_lookupByEmail(email=oncall_email)["user"]["id"] + except SlackApiError: + log.error(f"Failed to find Slack user for email: {oncall_email}") + return None + + def post_snooze_message( client: WebClient, channel: str, @@ -1081,6 +1111,8 @@ def post_snooze_message( db_session: Session, new_filter: SignalFilter, thread_ts: str | None = None, + extension_requested: bool = False, + oncall_service: Service | None = None, ): def extract_entity_ids(expression: list[dict]) -> list[int]: entity_ids = [] @@ -1105,13 +1137,20 @@ def extract_entity_ids(expression: list[dict]) -> list[int]: message = ( f":zzz: *New Signal Snooze Added*\n" - f"• User: {user.email}\n" + f"• Created by: {user.email}\n" f"• Signal: {signal.name}\n" f"• Snooze Name: {new_filter.name}\n" f"• Description: {new_filter.description}\n" f"• Expiration: {new_filter.expiration}\n" f"• Entities: {entities_text}" ) + if extension_requested: + message += "\n• *Extension Requested*" + if user_id := get_user_id_from_oncall_service( + client=client, db_session=db_session, oncall_service=oncall_service + ): + message += f" - notifying oncall: <@{user_id}>" + client.chat_postMessage(channel=channel, text=message, thread_ts=thread_ts) diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index d911f6326a31..ad0efa955c29 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -4,6 +4,7 @@ from typing import List from blockkit import ( + Checkboxes, DatePicker, Input, MultiExternalSelect, @@ -65,6 +66,7 @@ class DefaultBlockIds(DispatchEnum): # signals signal_definition_select = "signal-definition-select" + extension_request_checkbox = "extension_request_checkbox" # tags tags_multi_select = "tag-multi-select" @@ -102,6 +104,7 @@ class DefaultActionIds(DispatchEnum): # signals signal_definition_select = "signal-definition-select" + extension_request_checkbox = "extension_request_checkbox" # tags tags_multi_select = "tag-multi-select" @@ -740,3 +743,24 @@ def signal_definition_select( label=label, **kwargs, ) + + +def extension_request_checkbox( + action_id: str = DefaultActionIds.extension_request_checkbox, + block_id: str = DefaultBlockIds.extension_request_checkbox, + label: str = "Request longer expiration", + **kwargs, +): + options = [ + PlainOption( + text=("Check this box to request an expiration longer than 2 weeks."), + value="Yes", + ) + ] + return Input( + block_id=block_id, + element=Checkboxes(options=options, action_id=action_id), + label=label, + optional=True, + **kwargs, + ) diff --git a/src/dispatch/project/models.py b/src/dispatch/project/models.py index d7d974d3848a..6dc4edafefd3 100644 --- a/src/dispatch/project/models.py +++ b/src/dispatch/project/models.py @@ -72,6 +72,13 @@ class Project(Base): report_incident_title_hint = Column(String, nullable=True) report_incident_description_hint = Column(String, nullable=True) + snooze_extension_oncall_service_id = Column(Integer, nullable=True) + snooze_extension_oncall_service = relationship( + "Service", + foreign_keys=[snooze_extension_oncall_service_id], + primaryjoin="Service.id == Project.snooze_extension_oncall_service_id", + ) + @hybrid_property def slug(self): return slugify(self.name) @@ -81,6 +88,15 @@ def slug(self): ) +class Service(DispatchBase): + id: PrimaryKey + description: Optional[str] = Field(None, nullable=True) + external_id: str + is_active: Optional[bool] = None + name: NameStr + type: Optional[str] = Field(None, nullable=True) + + class ProjectBase(DispatchBase): id: Optional[PrimaryKey] name: NameStr @@ -105,6 +121,7 @@ class ProjectBase(DispatchBase): report_incident_instructions: Optional[str] = Field(None, nullable=True) report_incident_title_hint: Optional[str] = Field(None, nullable=True) report_incident_description_hint: Optional[str] = Field(None, nullable=True) + snooze_extension_oncall_service: Optional[Service] class ProjectCreate(ProjectBase): @@ -116,6 +133,7 @@ class ProjectUpdate(ProjectBase): send_weekly_reports: Optional[bool] = Field(False, nullable=True) weekly_report_notification_id: Optional[int] = Field(None, nullable=True) stable_priority_id: Optional[int] + snooze_extension_oncall_service_id: Optional[int] class ProjectRead(ProjectBase): diff --git a/src/dispatch/static/dispatch/src/project/NewEditSheet.vue b/src/dispatch/static/dispatch/src/project/NewEditSheet.vue index 33ffcb963cc6..30a93c5a9f15 100644 --- a/src/dispatch/static/dispatch/src/project/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/project/NewEditSheet.vue @@ -130,6 +130,15 @@ name="Owner Conversation" /> + + + + + Alternative folder structure { diff --git a/tests/plugins/dispatch_slack/case/test_interactive.py b/tests/plugins/dispatch_slack/case/test_interactive.py new file mode 100644 index 000000000000..ec5322ffeb01 --- /dev/null +++ b/tests/plugins/dispatch_slack/case/test_interactive.py @@ -0,0 +1,432 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest +from slack_sdk.errors import SlackApiError + +from dispatch.auth.models import MfaChallengeStatus +from dispatch.plugins.dispatch_slack.case.interactive import ( + handle_snooze_submission_event, + get_user_id_from_oncall_service, + post_snooze_message, +) + + +class TestHandleSnoozeSubmissionEvent: + @pytest.fixture + def mock_ack(self): + return MagicMock() + + @pytest.fixture + def mock_body(self): + return {"view": {"id": "view_id"}} + + @pytest.fixture + def mock_client(self): + return MagicMock() + + @pytest.fixture + def mock_context(self): + relative_date_picker = MagicMock() + relative_date_picker.value = "1 day, 0:00:00" + + extension_checkbox_item = MagicMock() + extension_checkbox_item.value = "Yes" + + entity_select_item = MagicMock() + entity_select_item.value = "789" + + return { + "subject": MagicMock( + id="123", + project_id="456", + channel_id="C123", + thread_id="T123", + form_data={ + "title-input": "Test Snooze", + "description-input": "Test Description", + "relative-date-picker-input": relative_date_picker, + "extension-request-checkbox": [extension_checkbox_item], + "entity-select": [entity_select_item], + }, + dict=MagicMock(return_value={}), + ) + } + + @pytest.fixture + def mock_db_session(self): + return MagicMock() + + @pytest.fixture + def mock_user(self): + return MagicMock(email="test@example.com") + + @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message") + @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal") + @patch("dispatch.plugins.dispatch_slack.case.interactive.project_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.SignalFilterCreate") + def test_handle_snooze_submission_event_no_mfa( + self, + mock_signal_filter_create, + mock_project_service, + mock_send_success_modal, + mock_post_snooze_message, + mock_signal_service, + mock_plugin_service, + mock_ack, + mock_body, + mock_client, + mock_context, + mock_db_session, + mock_user, + ): + # Setup + mock_plugin_service.get_active_instance.return_value = None + mock_signal = MagicMock() + mock_signal_service.get.return_value = mock_signal + mock_new_filter = MagicMock() + mock_signal_service.create_signal_filter.return_value = mock_new_filter + + # Mock the project + mock_project = MagicMock() + mock_project.id = "456" + mock_project.name = "Test Project" + mock_project.organization.slug = "test-org" + mock_project_service.get.return_value = mock_project + + # Mock the SignalFilterCreate to avoid validation errors + mock_filter_instance = MagicMock() + mock_signal_filter_create.return_value = mock_filter_instance + + # Execute + handle_snooze_submission_event( + ack=mock_ack, + body=mock_body, + client=mock_client, + context=mock_context, + db_session=mock_db_session, + user=mock_user, + ) + + # Assert + mock_signal_service.create_signal_filter.assert_called_once() + # The implementation calls get twice, so we don't assert the exact number of calls + assert mock_signal_service.get.call_count >= 1 + mock_post_snooze_message.assert_called_once() + mock_send_success_modal.assert_called_once() + + @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message") + @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal") + @patch("dispatch.plugins.dispatch_slack.case.interactive.project_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.SignalFilterCreate") + @patch("dispatch.plugins.dispatch_slack.case.interactive.ack_mfa_required_submission_event") + def test_handle_snooze_submission_event_with_mfa_approved( + self, + mock_ack_mfa_required, + mock_signal_filter_create, + mock_project_service, + mock_send_success_modal, + mock_post_snooze_message, + mock_signal_service, + mock_plugin_service, + mock_ack, + mock_body, + mock_client, + mock_context, + mock_db_session, + mock_user, + ): + # Setup + mock_mfa_plugin = MagicMock() + mock_mfa_plugin.instance.create_mfa_challenge.return_value = (MagicMock(), "challenge_url") + mock_mfa_plugin.instance.wait_for_challenge.return_value = MfaChallengeStatus.APPROVED + mock_plugin_service.get_active_instance.return_value = mock_mfa_plugin + + mock_signal = MagicMock() + mock_signal_service.get.return_value = mock_signal + mock_new_filter = MagicMock() + mock_signal_service.create_signal_filter.return_value = mock_new_filter + + # Mock the project + mock_project = MagicMock() + mock_project.id = "456" + mock_project.name = "Test Project" + mock_project.organization.slug = "test-org" + mock_project_service.get.return_value = mock_project + + # Mock the SignalFilterCreate to avoid validation errors + mock_filter_instance = MagicMock() + mock_signal_filter_create.return_value = mock_filter_instance + + # Execute + handle_snooze_submission_event( + ack=mock_ack, + body=mock_body, + client=mock_client, + context=mock_context, + db_session=mock_db_session, + user=mock_user, + ) + + # Assert + mock_mfa_plugin.instance.create_mfa_challenge.assert_called_once() + mock_mfa_plugin.instance.wait_for_challenge.assert_called_once() + mock_signal_service.create_signal_filter.assert_called_once() + # The implementation calls get twice, so we don't assert the exact number of calls + assert mock_signal_service.get.call_count >= 1 + mock_post_snooze_message.assert_called_once() + mock_send_success_modal.assert_called_once() + assert mock_user.last_mfa_time is not None + + @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message") + @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal") + @patch("dispatch.plugins.dispatch_slack.case.interactive.ack_mfa_required_submission_event") + def test_handle_snooze_submission_event_with_mfa_denied( + self, + mock_ack_mfa, + mock_send_success_modal, + mock_post_snooze_message, + mock_signal_service, + mock_plugin_service, + mock_ack, + mock_body, + mock_client, + mock_context, + mock_db_session, + mock_user, + ): + # Setup + mock_mfa_plugin = MagicMock() + mock_mfa_plugin.instance.create_mfa_challenge.return_value = (MagicMock(), "challenge_url") + mock_mfa_plugin.instance.wait_for_challenge.return_value = MfaChallengeStatus.DENIED + mock_plugin_service.get_active_instance.return_value = mock_mfa_plugin + + # Execute + handle_snooze_submission_event( + ack=mock_ack, + body=mock_body, + client=mock_client, + context=mock_context, + db_session=mock_db_session, + user=mock_user, + ) + + # Assert + mock_mfa_plugin.instance.create_mfa_challenge.assert_called_once() + mock_mfa_plugin.instance.wait_for_challenge.assert_called_once() + mock_signal_service.create_signal_filter.assert_not_called() + mock_signal_service.get.assert_not_called() + mock_post_snooze_message.assert_not_called() + mock_client.views_update.assert_called_once() + + +class TestGetUserIdFromOncallService: + @pytest.fixture + def mock_client(self): + return MagicMock() + + @pytest.fixture + def mock_db_session(self): + return MagicMock() + + @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows") + def test_get_user_id_from_oncall_service_none_service( + self, mock_service_flows, mock_client, mock_db_session + ): + # Setup + oncall_service = None + + # Execute + result = get_user_id_from_oncall_service( + client=mock_client, db_session=mock_db_session, oncall_service=oncall_service + ) + + # Assert + assert result is None + mock_service_flows.resolve_oncall.assert_not_called() + mock_client.users_lookupByEmail.assert_not_called() + + @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows") + def test_get_user_id_from_oncall_service_no_email( + self, mock_service_flows, mock_client, mock_db_session + ): + # Setup + oncall_service = MagicMock() + mock_service_flows.resolve_oncall.return_value = None + + # Execute + result = get_user_id_from_oncall_service( + client=mock_client, db_session=mock_db_session, oncall_service=oncall_service + ) + + # Assert + assert result is None + mock_service_flows.resolve_oncall.assert_called_once_with( + service=oncall_service, db_session=mock_db_session + ) + mock_client.users_lookupByEmail.assert_not_called() + + @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows") + def test_get_user_id_from_oncall_service_success( + self, mock_service_flows, mock_client, mock_db_session + ): + # Setup + oncall_service = MagicMock() + mock_service_flows.resolve_oncall.return_value = "oncall@example.com" + mock_client.users_lookupByEmail.return_value = {"user": {"id": "U123"}} + + # Execute + result = get_user_id_from_oncall_service( + client=mock_client, db_session=mock_db_session, oncall_service=oncall_service + ) + + # Assert + assert result == "U123" + mock_service_flows.resolve_oncall.assert_called_once_with( + service=oncall_service, db_session=mock_db_session + ) + mock_client.users_lookupByEmail.assert_called_once_with(email="oncall@example.com") + + @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows") + def test_get_user_id_from_oncall_service_slack_error( + self, mock_service_flows, mock_client, mock_db_session + ): + # Setup + oncall_service = MagicMock() + mock_service_flows.resolve_oncall.return_value = "oncall@example.com" + mock_client.users_lookupByEmail.side_effect = SlackApiError("Error", {"error": "not_found"}) + + # Execute + result = get_user_id_from_oncall_service( + client=mock_client, db_session=mock_db_session, oncall_service=oncall_service + ) + + # Assert + assert result is None + mock_service_flows.resolve_oncall.assert_called_once_with( + service=oncall_service, db_session=mock_db_session + ) + mock_client.users_lookupByEmail.assert_called_once_with(email="oncall@example.com") + + +class TestPostSnoozeMessage: + @pytest.fixture + def mock_client(self): + return MagicMock() + + @pytest.fixture + def mock_db_session(self): + return MagicMock() + + @pytest.fixture + def mock_user(self): + return MagicMock(email="test@example.com") + + @pytest.fixture + def mock_signal(self): + return MagicMock(name="Test Signal") + + @pytest.fixture + def mock_new_filter(self): + return MagicMock( + name="Test Filter", + description="Test Description", + expiration=datetime.now(tz=timezone.utc) + timedelta(days=1), + expression=[], + ) + + def test_post_snooze_message_no_entities( + self, mock_client, mock_db_session, mock_user, mock_signal, mock_new_filter + ): + # Setup + channel = "C123" + thread_ts = "T123" + extension_requested = False + oncall_service = None + + # Execute + post_snooze_message( + client=mock_client, + channel=channel, + user=mock_user, + signal=mock_signal, + db_session=mock_db_session, + new_filter=mock_new_filter, + thread_ts=thread_ts, + extension_requested=extension_requested, + oncall_service=oncall_service, + ) + + # Assert + mock_client.chat_postMessage.assert_called_once() + args, kwargs = mock_client.chat_postMessage.call_args + assert kwargs["channel"] == channel + assert kwargs["thread_ts"] == thread_ts + assert "New Signal Snooze Added" in kwargs["text"] + assert "Entities: All" in kwargs["text"] + assert "Extension Requested" not in kwargs["text"] + + @patch("dispatch.plugins.dispatch_slack.case.interactive.entity_service") + @patch("dispatch.plugins.dispatch_slack.case.interactive.get_user_id_from_oncall_service") + def test_post_snooze_message_with_entities_and_extension( + self, + mock_get_user_id, + mock_entity_service, + mock_client, + mock_db_session, + mock_user, + mock_signal, + mock_new_filter, + ): + # Setup + channel = "C123" + thread_ts = "T123" + extension_requested = True + oncall_service = MagicMock() + + # Setup entity expression + mock_new_filter.expression = [ + { + "or": [ + {"model": "Entity", "field": "id", "value": "123"}, + {"model": "Entity", "field": "id", "value": "456"}, + ] + } + ] + + # Setup entities + mock_entity1 = MagicMock(id=123, value="entity1") + mock_entity2 = MagicMock(id=456, value="entity2") + mock_entity_service.get.side_effect = [mock_entity1, mock_entity2] + + # Setup oncall user + mock_get_user_id.return_value = "U123" + + # Execute + post_snooze_message( + client=mock_client, + channel=channel, + user=mock_user, + signal=mock_signal, + db_session=mock_db_session, + new_filter=mock_new_filter, + thread_ts=thread_ts, + extension_requested=extension_requested, + oncall_service=oncall_service, + ) + + # Assert + mock_client.chat_postMessage.assert_called_once() + args, kwargs = mock_client.chat_postMessage.call_args + assert kwargs["channel"] == channel + assert kwargs["thread_ts"] == thread_ts + assert "New Signal Snooze Added" in kwargs["text"] + assert "Entities: entity1 (123), entity2 (456)" in kwargs["text"] + assert "Extension Requested" in kwargs["text"] + assert "notifying oncall: <@U123>" in kwargs["text"]