Skip to content

Commit

Permalink
feat(signal instance): allow instance to specify oncall service and c…
Browse files Browse the repository at this point in the history
…onversation target (#5670)

* interim

* db update

* remove comment

* pair programming and test suite

* unused import

* Update src/dispatch/database/revisions/tenant/versions/2024-12-17_dfc8e213a2c4.py

Co-authored-by: David Whittaker <[email protected]>
Signed-off-by: Alicia Matsumoto <[email protected]>

---------

Signed-off-by: Alicia Matsumoto <[email protected]>
Co-authored-by: Alicia Matsumoto <[email protected]>
Co-authored-by: David Whittaker <[email protected]>
  • Loading branch information
3 people authored Jan 10, 2025
1 parent c1d8b99 commit 17ac3e2
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Adds conversation target and oncall service override options to signal instances
Revision ID: dfc8e213a2c4
Revises: 2d9e4d392ea4
Create Date: 2024-12-17 10:02:26.920568
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'dfc8e213a2c4'
down_revision = '2d9e4d392ea4'
branch_labels = None
depends_on = None


def upgrade():
op.add_column("signal_instance", sa.Column("conversation_target", sa.String(), nullable=True))
op.add_column("signal_instance", sa.Column("oncall_service_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"oncall_service_id_fkey",
"signal_instance",
"service",
["oncall_service_id"],
["id"]
)


def downgrade():
op.drop_constraint("oncall_service_id_fkey", "signal_instance", type_="foreignkey")
op.drop_column("signal_instance", "oncall_service_id")
op.drop_column("signal_instance", "conversation_target")
47 changes: 27 additions & 20 deletions src/dispatch/signal/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,38 @@ def signal_instance_create_flow(
if not signal_instance.signal.create_case:
return signal_instance

# processes overrides for case creation
# we want the following order of precedence:
# 1. signal instance overrides
# 2. signal definition overrides
# 3. case type defaults
# set signal instance attributes with priority given to signal instance specification, then signal, then case type.
if signal_instance.case_type:
case_type = signal_instance.case_type
else:
case_type = signal_instance.signal.case_type

if signal_instance.case_priority:
case_priority = signal_instance.case_priority
else:
case_priority = signal_instance.signal.case_priority

# if the signal has provided a case type use it's values instead of the definitions
conversation_target = None
if signal_instance.case_type:
case_type = signal_instance.case_type
if signal_instance.signal.conversation_target:
conversation_target = signal_instance.case_type.conversation_target
if signal_instance.oncall_service:
oncall_service = signal_instance.oncall_service
elif signal_instance.signal.oncall_service:
oncall_service = signal_instance.signal.oncall_service
elif case_type.oncall_service:
oncall_service = case_type.oncall_service
else:
case_type = signal_instance.signal.case_type

if signal_instance.signal.conversation_target:
conversation_target = signal_instance.signal.conversation_target
oncall_service = None

if signal_instance.conversation_target:
conversation_target = signal_instance.conversation_target
elif signal_instance.signal.conversation_target:
conversation_target = signal_instance.signal.conversation_target
elif case_type.conversation_target:
conversation_target = case_type.conversation_target
else:
conversation_target = None

assignee = None
if signal_instance.signal.oncall_service:
email = service_flows.resolve_oncall(
service=signal_instance.signal.oncall_service, db_session=db_session
)
if oncall_service:
email = service_flows.resolve_oncall(service=oncall_service, db_session=db_session)
assignee = {"individual": {"email": email}}

# create a case if not duplicate or snoozed and case creation is enabled
Expand Down Expand Up @@ -172,7 +176,10 @@ def create_signal_instance(
raise DispatchException("Signal definition is not enabled.")

signal_instance_in = SignalInstanceCreate(
raw=signal_instance_data, signal=signal, project=signal.project
**signal_instance_data,
raw=signal_instance_data,
signal=signal,
project=signal.project,
)

signal_instance = signal_service.create_instance(
Expand Down
6 changes: 6 additions & 0 deletions src/dispatch/signal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
TimeStampMixin,
)
from dispatch.project.models import ProjectRead
from dispatch.service.models import Service, ServiceRead
from dispatch.tag.models import TagRead
from dispatch.workflow.models import WorkflowRead

Expand Down Expand Up @@ -228,8 +229,11 @@ class SignalInstance(Base, TimeStampMixin, ProjectMixin):
case_type = relationship("CaseType", backref="signal_instances")
case_priority_id = Column(Integer, ForeignKey(CasePriority.id))
case_priority = relationship("CasePriority", backref="signal_instances")
conversation_target = Column(String)
filter_action = Column(String)
canary = Column(Boolean, default=False)
oncall_service_id = Column(Integer, ForeignKey(Service.id))
oncall_service = relationship("Service", backref="signal_instances")
raw = Column(JSONB)
signal = relationship("Signal", backref="instances")
signal_id = Column(Integer, ForeignKey("signal.id"))
Expand Down Expand Up @@ -386,6 +390,8 @@ class SignalInstanceCreate(SignalInstanceBase):
signal: Optional[SignalRead]
case_priority: Optional[CasePriorityRead]
case_type: Optional[CaseTypeRead]
conversation_target: Optional[str]
oncall_service: Optional[ServiceRead]


class SignalInstanceRead(SignalInstanceBase):
Expand Down
16 changes: 15 additions & 1 deletion src/dispatch/signal/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,11 @@ def create_instance(

signal = get(db_session=db_session, signal_id=signal_instance_in.signal.id)

# remove non-serializable entities from the raw JSON:
signal_instance_in_raw = signal_instance_in.raw.copy()
if signal_instance_in.oncall_service:
signal_instance_in_raw.pop('oncall_service')

# we round trip the raw data to json-ify date strings
signal_instance = SignalInstance(
**signal_instance_in.dict(
Expand All @@ -580,12 +585,13 @@ def create_instance(
"case_type",
"entities",
"external_id",
"oncall_service",
"project",
"raw",
"signal",
}
),
raw=json.loads(json.dumps(signal_instance_in.raw)),
raw=json.loads(json.dumps(signal_instance_in_raw)),
project=project,
signal=signal,
)
Expand Down Expand Up @@ -619,6 +625,14 @@ def create_instance(
)
signal_instance.case_type = case_type

if signal_instance_in.oncall_service:
oncall_service = service_service.get_by_name(
db_session=db_session,
project_id=project.id,
name=signal_instance_in.oncall_service.name,
)
signal_instance.oncall_service = oncall_service

db_session.add(signal_instance)
db_session.commit()
return signal_instance
Expand Down
179 changes: 179 additions & 0 deletions tests/signal/test_signal_flow.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest import mock

import pytest

from dispatch.exceptions import DispatchException
Expand Down Expand Up @@ -59,3 +61,180 @@ def test_create_signal_instance_not_enabled(session, signal, case_severity, case
signal_instance_data=instance_data,
current_user=user,
)

def test_create_signal_instance_custom_conversation_target(session, signal, case_severity, case_priority, user, case_type):
from dispatch.signal.flows import create_signal_instance

case_priority.default = True
case_priority.project_id = signal.project_id

case_severity.default = True
case_severity.project_id = signal.project_id

instance_data = {"variant": signal.variant, "conversation_target": "instance-conversation-target"}
signal.conversation_target = "signal-conversation-target"

signal_instance = create_signal_instance(
db_session=session,
project=signal.project,
signal_instance_data=instance_data,
current_user=user,
)
assert signal_instance.conversation_target == 'instance-conversation-target'


def test_create_signal_instance_custom_oncall_service(session, signal, case_severity, case_priority, user, services):
from dispatch.signal.flows import create_signal_instance

case_priority.default = True
case_priority.project_id = signal.project_id

case_severity.default = True
case_severity.project_id = signal.project_id

service_0, service_1 = services
service_0.project_id = signal.project_id
service_1.project_id = signal.project_id

signal.oncall_service = service_0
instance_data = {"variant": signal.variant, "oncall_service": service_1}

signal_instance = create_signal_instance(
db_session=session,
project=signal.project,
signal_instance_data=instance_data,
current_user=user,
)
assert signal_instance.oncall_service.id == service_1.id

def test_signal_instance_create_flow_custom_attributes(session, signal, case_severity, case_priority, user, services, signal_instance, oncall_plugin, case_type, case):
from dispatch.signal.flows import signal_instance_create_flow
from dispatch.service import flows as service_flows
from dispatch.case import service as case_service

case_priority.default = True
case_priority.project_id = signal.project_id

case_severity.default = True
case_severity.project_id = signal.project_id

service_0, service_1 = services
service_0.project_id = signal.project_id
service_1.project_id = signal.project_id

signal_instance.oncall_service = service_0
signal_instance.signal.oncall_service = service_1
signal_instance.conversation_target = "instance-conversation-target"
signal_instance.signal.conversation_target = "signal-conversation-target"

with mock.patch.object(service_flows, "resolve_oncall") as mock_resolve_oncall, \
mock.patch.object(case_service, "create") as mock_case_create, \
mock.patch("dispatch.case.flows.case_new_create_flow") as mock_case_new_create_flow:
mock_resolve_oncall.side_effect = lambda service, db_session: "[email protected]" if service.id == service_0.id else None
mock_case_create.return_value = case

post_flow_instance = signal_instance_create_flow(
signal_instance_id=signal_instance.id,
db_session=session,
current_user=user
)
case_in_arg = mock_case_create.call_args[1]['case_in']
assert case_in_arg.assignee.individual.email == "[email protected]"
mock_case_new_create_flow.assert_called_once_with(
db_session=session,
organization_slug=None,
service_id=None,
conversation_target="instance-conversation-target",
case_id=post_flow_instance.case.id,
create_all_resources=False
)

def test_signal_instance_create_flow_use_signal_attributes(session, signal, case_severity, case_priority, user, services, signal_instance,
case_type, case):
"""
If the signal instance does not specify a conversation target or on-call service, use the signal's configurations
before the case type's configurations.
"""
from dispatch.signal.flows import signal_instance_create_flow
from dispatch.service import flows as service_flows
from dispatch.case import service as case_service

case_priority.default = True
case_priority.project_id = signal.project_id

case_severity.default = True
case_severity.project_id = signal.project_id

service_0, service_1 = services
service_0.project_id = signal.project_id
service_1.project_id = signal.project_id

signal_instance.signal.oncall_service = service_0
signal_instance.signal.conversation_target = "signal-conversation-target"
case_type.oncall_service = service_1
case_type.conversation_target = "case-type-conversation-target"
signal_instance.signal.case_type = case_type

with mock.patch.object(service_flows, "resolve_oncall") as mock_resolve_oncall, \
mock.patch.object(case_service, "create") as mock_case_create, \
mock.patch("dispatch.case.flows.case_new_create_flow") as mock_case_new_create_flow:

mock_resolve_oncall.side_effect = lambda service, db_session: "[email protected]" if service.id == service_0.id else None
mock_case_create.return_value = case

post_flow_instance = signal_instance_create_flow(
signal_instance_id=signal_instance.id,
db_session=session,
current_user=user
)
case_in_arg = mock_case_create.call_args[1]['case_in']
assert case_in_arg.assignee.individual.email == "[email protected]"
mock_case_new_create_flow.assert_called_once_with(
db_session=session,
organization_slug=None,
service_id=None,
conversation_target="signal-conversation-target",
case_id=post_flow_instance.case.id,
create_all_resources=False
)


def test_signal_instance_create_flow_use_case_type_attributes(session, signal, case_severity, case_priority, user, service, case, signal_instance, case_type):
"""
If the signal instance and the signal both do not specify conversation targets or on-call services, use the case type's configurations.
"""
from dispatch.signal.flows import signal_instance_create_flow
from dispatch.service import flows as service_flows
from dispatch.case import service as case_service

case_priority.default = True
case_priority.project_id = signal.project_id

case_severity.default = True
case_severity.project_id = signal.project_id

case_type.oncall_service = service
case_type.conversation_target = "case-type-conversation-target"
signal_instance.signal.case_type = case_type

with mock.patch.object(service_flows, "resolve_oncall") as mock_resolve_oncall, \
mock.patch.object(case_service, "create") as mock_case_create, \
mock.patch("dispatch.case.flows.case_new_create_flow") as mock_case_new_create_flow:
mock_resolve_oncall.side_effect = lambda service, db_session: "[email protected]"
mock_case_create.return_value = case

post_flow_instance = signal_instance_create_flow(
signal_instance_id=signal_instance.id,
db_session=session,
current_user=user
)
case_in_arg = mock_case_create.call_args[1]['case_in']
assert case_in_arg.assignee.individual.email == "[email protected]"
mock_case_new_create_flow.assert_called_once_with(
db_session=session,
organization_slug=None,
service_id=None,
conversation_target="case-type-conversation-target",
case_id=post_flow_instance.case.id,
create_all_resources=False
)

0 comments on commit 17ac3e2

Please sign in to comment.