diff --git a/requirements-base.txt b/requirements-base.txt
index b174b8920ee9..0775b5d9bc6e 100644
--- a/requirements-base.txt
+++ b/requirements-base.txt
@@ -114,7 +114,7 @@ email-validator==2.0.0.post2
# via -r requirements-base.in
emails==0.6
# via -r requirements-base.in
-fastapi==0.103.1
+fastapi==0.103.2
# via -r requirements-base.in
frozenlist==1.4.0
# via
@@ -207,7 +207,7 @@ markupsafe==2.1.3
# jinja2
# mako
# werkzeug
-msal==1.24.0
+msal==1.24.1
# via -r requirements-base.in
multidict==6.0.4
# via
@@ -272,7 +272,7 @@ protobuf==4.24.3
# -r requirements-base.in
# google-api-core
# googleapis-common-protos
-psycopg2-binary==2.9.7
+psycopg2-binary==2.9.8
# via -r requirements-base.in
pyasn1==0.5.0
# via
@@ -286,7 +286,7 @@ pyasn1-modules==0.3.0
# oauth2client
pycparser==2.21
# via cffi
-pydantic==1.10.12
+pydantic==1.10.13
# via
# -r requirements-base.in
# blockkit
diff --git a/requirements-dev.txt b/requirements-dev.txt
index c31768d3beee..c8d639b2ce32 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -44,7 +44,7 @@ identify==2.5.27
# via pre-commit
iniconfig==2.0.0
# via pytest
-ipython==8.15.0
+ipython==8.16.0
# via -r requirements-dev.in
jedi==0.19.0
# via ipython
diff --git a/src/dispatch/api.py b/src/dispatch/api.py
index 882a08d99b4d..21c84e50de7d 100644
--- a/src/dispatch/api.py
+++ b/src/dispatch/api.py
@@ -24,6 +24,7 @@
from dispatch.entity.views import router as entity_router
from dispatch.entity_type.views import router as entity_type_router
from dispatch.feedback.incident.views import router as feedback_router
+from dispatch.feedback.service.views import router as service_feedback_router
from dispatch.incident.priority.views import router as incident_priority_router
from dispatch.incident.severity.views import router as incident_severity_router
from dispatch.incident.type.views import router as incident_type_router
@@ -203,6 +204,9 @@ def get_organization_path(organization: OrganizationSlug):
authenticated_organization_api_router.include_router(
feedback_router, prefix="/feedback", tags=["feedback"]
)
+authenticated_organization_api_router.include_router(
+ service_feedback_router, prefix="/service_feedback", tags=["service_feedback"]
+)
authenticated_organization_api_router.include_router(
notification_router, prefix="/notifications", tags=["notifications"]
)
diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py
index d6cd1828d2d8..56827f9ca588 100644
--- a/src/dispatch/auth/models.py
+++ b/src/dispatch/auth/models.py
@@ -54,6 +54,7 @@ class DispatchUser(Base, TimeStampMixin):
email = Column(String, unique=True)
password = Column(LargeBinary, nullable=False)
last_mfa_time = Column(DateTime, nullable=True)
+ experimental_features = Column(Boolean, default=False)
# relationships
events = relationship("Event", backref="dispatch_user")
@@ -157,6 +158,7 @@ class UserLoginResponse(DispatchBase):
class UserRead(UserBase):
id: PrimaryKey
role: Optional[str] = Field(None, nullable=True)
+ experimental_features: Optional[bool]
class UserUpdate(DispatchBase):
@@ -164,6 +166,7 @@ class UserUpdate(DispatchBase):
password: Optional[str] = Field(None, nullable=True)
projects: Optional[List[UserProject]]
organizations: Optional[List[UserOrganization]]
+ experimental_features: Optional[bool]
role: Optional[str] = Field(None, nullable=True)
@validator("password", pre=True)
diff --git a/src/dispatch/auth/service.py b/src/dispatch/auth/service.py
index 29f8ca5be8ee..8d2b57ef1385 100644
--- a/src/dispatch/auth/service.py
+++ b/src/dispatch/auth/service.py
@@ -245,6 +245,9 @@ def update(*, db_session, user: DispatchUser, user_in: UserUpdate) -> DispatchUs
)
)
+ if experimental_features := user_in.experimental_features:
+ user.experimental_features = experimental_features
+
db_session.commit()
return user
diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py
index 4f64d6ee717c..6c06cb88113d 100644
--- a/src/dispatch/case/flows.py
+++ b/src/dispatch/case/flows.py
@@ -6,8 +6,7 @@
from dispatch.case import service as case_service
from dispatch.case.models import CaseRead
-from dispatch.conversation import service as conversation_service
-from dispatch.conversation.models import ConversationCreate
+from dispatch.conversation import flows as conversation_flows
from dispatch.database.core import SessionLocal
from dispatch.decorators import background_task
from dispatch.document import flows as document_flows
@@ -154,26 +153,6 @@ def case_add_or_reactivate_participant_flow(
return participant
-def create_conversation(case: Case, conversation_target: str, db_session: SessionLocal):
- """Create external communication conversation."""
- plugin = plugin_service.get_active_instance(
- db_session=db_session, project_id=case.project.id, plugin_type="conversation"
- )
- conversation = plugin.instance.create_threaded(
- case=case, conversation_id=conversation_target, db_session=db_session
- )
- conversation.update({"resource_type": plugin.plugin.slug, "resource_id": conversation["id"]})
-
- event_service.log_case_event(
- db_session=db_session,
- source=plugin.plugin.title,
- description="Case conversation created",
- case_id=case.id,
- )
-
- return conversation
-
-
def update_conversation(case: Case, db_session: SessionLocal):
"""Updates external communication conversation."""
plugin = plugin_service.get_active_instance(
@@ -217,6 +196,7 @@ def case_new_create_flow(
case_id=case.id,
individual_participants=individual_participants,
team_participants=team_participants,
+ conversation_target=conversation_target,
)
if case.case_priority.page_assignee:
@@ -241,67 +221,6 @@ def case_new_create_flow(
else:
log.warning("Case assignee not paged. No plugin of type oncall enabled.")
- conversation_plugin = plugin_service.get_active_instance(
- db_session=db_session, project_id=case.project.id, plugin_type="conversation"
- )
- if conversation_plugin:
- if not conversation_target:
- conversation_target = case.case_type.conversation_target
- if conversation_target:
- try:
- # TODO: Refactor conversation creation using conversation_flows module
- conversation = create_conversation(case, conversation_target, db_session)
- conversation_in = ConversationCreate(
- resource_id=conversation["resource_id"],
- resource_type=conversation["resource_type"],
- weblink=conversation["weblink"],
- thread_id=conversation["timestamp"],
- channel_id=conversation["id"],
- )
- case.conversation = conversation_service.create(
- db_session=db_session, conversation_in=conversation_in
- )
-
- event_service.log_case_event(
- db_session=db_session,
- source="Dispatch Core App",
- description="Conversation added to case",
- case_id=case.id,
- )
- # wait until all resources are created before adding suggested participants
- individual_participants = [x.email for x, _ in individual_participants]
-
- for email in individual_participants:
- # we don't rely on on this flow to add folks to the conversation because in this case
- # we want to do it in bulk
- case_add_or_reactivate_participant_flow(
- db_session=db_session,
- user_email=email,
- case_id=case.id,
- add_to_conversation=False,
- )
- # explicitly add the assignee to the conversation
- all_participants = individual_participants + [case.assignee.individual.email]
- conversation_plugin.instance.add_to_thread(
- case.conversation.channel_id,
- case.conversation.thread_id,
- all_participants,
- )
- event_service.log_case_event(
- db_session=db_session,
- source="Dispatch Core App",
- description="Case participants added to conversation.",
- case_id=case.id,
- )
- except Exception as e:
- event_service.log_case_event(
- db_session=db_session,
- source="Dispatch Core App",
- description=f"Creation of case conversation failed. Reason: {e}",
- case_id=case.id,
- )
- log.exception(e)
-
db_session.add(case)
db_session.commit()
@@ -684,7 +603,12 @@ def case_assign_role_flow(
def case_create_resources_flow(
- db_session: Session, case_id: int, individual_participants: list, team_participants: list
+ db_session: Session,
+ case_id: int,
+ individual_participants: List[str],
+ team_participants: List[str],
+ conversation_target: str = None,
+ create_resources: bool = True,
) -> None:
"""Runs the case resource creation flow."""
case = get(db_session=db_session, case_id=case_id)
@@ -692,43 +616,93 @@ def case_create_resources_flow(
if case.assignee:
individual_participants.append((case.assignee.individual, None))
- # we create the tactical group
- direct_participant_emails = [i.email for i, _ in individual_participants]
+ if create_resources:
+ # we create the tactical group
+ direct_participant_emails = [i.email for i, _ in individual_participants]
- indirect_participant_emails = [t.email for t in team_participants]
+ indirect_participant_emails = [t.email for t in team_participants]
- group = group_flows.create_group(
- subject=case,
- group_type=GroupType.tactical,
- group_participants=list(set(direct_participant_emails + indirect_participant_emails)),
- db_session=db_session,
- )
+ if not case.groups:
+ group_flows.create_group(
+ subject=case,
+ group_type=GroupType.tactical,
+ group_participants=list(
+ set(direct_participant_emails + indirect_participant_emails)
+ ),
+ db_session=db_session,
+ )
- # we create the storage folder
- storage_members = []
- if group:
- storage_members = [group.email]
+ # we create the storage folder
+ storage_members = []
+ if case.tactical_group:
+ storage_members = [case.tactical_group.email]
+ # direct add members if not group exists
+ else:
+ storage_members = direct_participant_emails
- # direct add members if not group exists
- else:
- storage_members = direct_participant_emails
+ if not case.storage:
+ storage_flows.create_storage(
+ subject=case, storage_members=storage_members, db_session=db_session
+ )
- case.storage = storage_flows.create_storage(
- subject=case, storage_members=storage_members, db_session=db_session
- )
+ # we create the investigation document
+ if not case.case_document:
+ document_flows.create_document(
+ subject=case,
+ document_type=DocumentResourceTypes.case,
+ document_template=case.case_type.case_template_document,
+ db_session=db_session,
+ )
- # we create the investigation document
- document = document_flows.create_document(
- subject=case,
- document_type=DocumentResourceTypes.case,
- document_template=case.case_type.case_template_document,
- db_session=db_session,
- )
+ # we update the ticket
+ ticket_flows.update_case_ticket(case=case, db_session=db_session)
- # we update the ticket
- ticket_flows.update_case_ticket(case=case, db_session=db_session)
+ # we update the case document
+ document_flows.update_document(
+ document=case.case_document, project_id=case.project.id, db_session=db_session
+ )
- # we update the case document
- document_flows.update_document(
- document=document, project_id=case.project.id, db_session=db_session
- )
+ try:
+ # we create the conversation and add participants to the thread
+ conversation_flows.create_case_conversation(case, conversation_target, db_session)
+
+ event_service.log_case_event(
+ db_session=db_session,
+ source="Dispatch Core App",
+ description="Conversation added to case",
+ case_id=case.id,
+ )
+ # wait until all resources are created before adding suggested participants
+ individual_participants = [x.email for x, _ in individual_participants]
+
+ for email in individual_participants:
+ # we don't rely on on this flow to add folks to the conversation because in this case
+ # we want to do it in bulk
+ case_add_or_reactivate_participant_flow(
+ db_session=db_session,
+ user_email=email,
+ case_id=case.id,
+ add_to_conversation=False,
+ )
+ # explicitly add the assignee to the conversation
+ all_participants = individual_participants + [case.assignee.individual.email]
+
+ # # we add the participant to the conversation
+ conversation_flows.add_case_participants(
+ case=case, participant_emails=all_participants, db_session=db_session
+ )
+
+ event_service.log_case_event(
+ db_session=db_session,
+ source="Dispatch Core App",
+ description="Case participants added to conversation.",
+ case_id=case.id,
+ )
+ except Exception as e:
+ event_service.log_case_event(
+ db_session=db_session,
+ source="Dispatch Core App",
+ description=f"Creation of case conversation failed. Reason: {e}",
+ case_id=case.id,
+ )
+ log.exception(e)
diff --git a/src/dispatch/case/messaging.py b/src/dispatch/case/messaging.py
new file mode 100644
index 000000000000..9cb7780f6e1d
--- /dev/null
+++ b/src/dispatch/case/messaging.py
@@ -0,0 +1,87 @@
+"""
+.. module: dispatch.case.messaging
+ :platform: Unix
+ :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more
+ :license: Apache, see LICENSE for more details.
+"""
+import logging
+
+from dispatch.database.core import SessionLocal
+from dispatch.case.models import Case
+from dispatch.messaging.strings import (
+ CASE_CLOSE_REMINDER,
+ CASE_TRIAGE_REMINDER,
+ MessageType,
+)
+from dispatch.plugin import service as plugin_service
+
+
+log = logging.getLogger(__name__)
+
+
+def send_case_close_reminder(case: Case, db_session: SessionLocal):
+ """
+ Sends a direct message to the assignee reminding them to close the case if possible.
+ """
+ message_text = "Case Close Reminder"
+ message_template = CASE_CLOSE_REMINDER
+
+ plugin = plugin_service.get_active_instance(
+ db_session=db_session, project_id=case.project.id, plugin_type="conversation"
+ )
+ if not plugin:
+ log.warning("Case close reminder message not sent. No conversation plugin enabled.")
+ return
+
+ items = [
+ {
+ "name": case.name,
+ "ticket_weblink": case.ticket.weblink,
+ "title": case.title,
+ "status": case.status,
+ }
+ ]
+
+ plugin.instance.send_direct(
+ case.assignee.individual.email,
+ message_text,
+ message_template,
+ MessageType.case_status_reminder,
+ items=items,
+ )
+
+ log.debug(f"Case close reminder sent to {case.assignee.individual.email}.")
+
+
+def send_case_triage_reminder(case: Case, db_session: SessionLocal):
+ """
+ Sends a direct message to the assignee reminding them to triage the case if possible.
+ """
+ message_text = "Case Triage Reminder"
+ message_template = CASE_TRIAGE_REMINDER
+
+ plugin = plugin_service.get_active_instance(
+ db_session=db_session, project_id=case.project.id, plugin_type="conversation"
+ )
+ if not plugin:
+ log.warning("Case triage reminder message not sent. No conversation plugin enabled.")
+ return
+
+ items = [
+ {
+ "name": case.name,
+ "ticket_weblink": case.ticket.weblink,
+ "title": case.title,
+ "status": case.status,
+ }
+ ]
+
+ plugin.instance.send_direct(
+ case.assignee.individual.email,
+ message_text,
+ message_template,
+ MessageType.case_status_reminder,
+ items=items,
+ )
+
+ log.debug(f"Case triage reminder sent to {case.assignee.individual.email}.")
diff --git a/src/dispatch/case/scheduled.py b/src/dispatch/case/scheduled.py
new file mode 100644
index 000000000000..fa386bcf085e
--- /dev/null
+++ b/src/dispatch/case/scheduled.py
@@ -0,0 +1,47 @@
+from datetime import datetime, date
+from schedule import every
+
+from dispatch.database.core import SessionLocal
+from dispatch.decorators import scheduled_project_task, timer
+from dispatch.project.models import Project
+from dispatch.scheduler import scheduler
+
+from .enums import CaseStatus
+from .messaging import send_case_close_reminder, send_case_triage_reminder
+from .service import (
+ get_all_by_status,
+)
+
+
+@scheduler.add(every(1).day.at("18:00"), name="case-close-reminder")
+@timer
+@scheduled_project_task
+def case_close_reminder(db_session: SessionLocal, project: Project):
+ """Sends a reminder to the case assignee to close out their case."""
+ cases = get_all_by_status(
+ db_session=db_session, project_id=project.id, status=CaseStatus.triage
+ )
+
+ for case in cases:
+ span = datetime.utcnow() - case.triage_at
+ q, r = divmod(span.days, 7)
+ if q >= 1 and date.today().isoweekday() == 1:
+ # we only send the reminder for cases that have been triaging
+ # longer than a week and only on Mondays
+ send_case_close_reminder(case, db_session)
+
+
+@scheduler.add(every(1).day.at("18:00"), name="case-triage-reminder")
+@timer
+@scheduled_project_task
+def case_triage_reminder(db_session: SessionLocal, project: Project):
+ """Sends a reminder to the case assignee to triage their case."""
+ cases = get_all_by_status(db_session=db_session, project_id=project.id, status=CaseStatus.new)
+
+ # if we want more specific SLA reminders, we would need to add additional data model
+ for case in cases:
+ span = datetime.utcnow() - case.created
+ q, r = divmod(span.days, 1)
+ if q >= 1:
+ # we only send one reminder per case per day
+ send_case_triage_reminder(case, db_session)
diff --git a/src/dispatch/case/views.py b/src/dispatch/case/views.py
index 1d9e68f36e94..add6f8a5037a 100644
--- a/src/dispatch/case/views.py
+++ b/src/dispatch/case/views.py
@@ -34,6 +34,8 @@
case_new_create_flow,
case_triage_create_flow,
case_update_flow,
+ case_create_resources_flow,
+ get_case_participants,
)
from .models import Case, CaseCreate, CasePagination, CaseRead, CaseUpdate, CaseExpandedPagination
from .service import create, delete, get, update
@@ -145,6 +147,32 @@ def create_case(
return case
+@router.post(
+ "/{case_id}/resources",
+ response_model=CaseRead,
+ summary="Creates resources for an existing case.",
+)
+def create_case_resources(
+ db_session: DbSession,
+ case_id: PrimaryKey,
+ current_case: CurrentCase,
+ background_tasks: BackgroundTasks,
+):
+ """Creates resources for an existing case."""
+ individual_participants, team_participants = get_case_participants(
+ case=current_case, db_session=db_session
+ )
+ background_tasks.add_task(
+ case_create_resources_flow,
+ db_session=db_session,
+ case_id=case_id,
+ individual_participants=individual_participants,
+ team_participants=team_participants,
+ )
+
+ return current_case
+
+
@router.put(
"/{case_id}",
response_model=CaseRead,
diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py
index 7c6836c3b710..0675e157cbec 100644
--- a/src/dispatch/cli.py
+++ b/src/dispatch/cli.py
@@ -636,6 +636,7 @@ def dispatch_scheduler():
)
from .term.scheduled import sync_terms # noqa
from .workflow.scheduled import sync_workflows # noqa
+ from .case.scheduled import case_triage_reminder, case_close_reminder # noqa
@dispatch_scheduler.command("list")
diff --git a/src/dispatch/conversation/flows.py b/src/dispatch/conversation/flows.py
index ab14fbf4b4a3..dadba50ff470 100644
--- a/src/dispatch/conversation/flows.py
+++ b/src/dispatch/conversation/flows.py
@@ -2,6 +2,7 @@
from typing import TypeVar, List
+from dispatch.case.models import Case
from dispatch.conference.models import Conference
from dispatch.database.core import SessionLocal, resolve_attr
from dispatch.document.models import Document
@@ -22,7 +23,57 @@
Resource = TypeVar("Resource", Document, Conference, Storage, Ticket)
-def create_conversation(incident: Incident, db_session: SessionLocal):
+def create_case_conversation(case: Case, conversation_target: str, db_session: SessionLocal):
+ """Create external communication conversation."""
+
+ plugin = plugin_service.get_active_instance(
+ db_session=db_session, project_id=case.project.id, plugin_type="conversation"
+ )
+ if not plugin:
+ log.warning("Conversation not created. No conversation plugin enabled.")
+ return
+
+ if not conversation_target:
+ conversation_target = case.case_type.conversation_target
+
+ if conversation_target:
+ try:
+ conversation = plugin.instance.create_threaded(
+ case=case, conversation_id=conversation_target, db_session=db_session
+ )
+ except Exception as e:
+ # TODO: consistency across exceptions
+ log.exception(e)
+
+ if not conversation:
+ log.error(f"Conversation not created. Plugin {plugin.plugin.slug} encountered an error.")
+ return
+
+ conversation.update({"resource_type": plugin.plugin.slug, "resource_id": conversation["id"]})
+
+ conversation_in = ConversationCreate(
+ resource_id=conversation["resource_id"],
+ resource_type=conversation["resource_type"],
+ weblink=conversation["weblink"],
+ thread_id=conversation["timestamp"],
+ channel_id=conversation["id"],
+ )
+ case.conversation = create(db_session=db_session, conversation_in=conversation_in)
+
+ event_service.log_case_event(
+ db_session=db_session,
+ source=plugin.plugin.title,
+ description="Case conversation created",
+ case_id=case.id,
+ )
+
+ db_session.add(case)
+ db_session.commit()
+
+ return case.conversation
+
+
+def create_incident_conversation(incident: Incident, db_session: SessionLocal):
"""Creates a conversation."""
plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=incident.project.id, plugin_type="conversation"
@@ -59,8 +110,7 @@ def create_conversation(incident: Incident, db_session: SessionLocal):
weblink=external_conversation["weblink"],
channel_id=external_conversation["id"],
)
- conversation = create(conversation_in=conversation_in, db_session=db_session)
- incident.conversation = conversation
+ incident.conversation = create(conversation_in=conversation_in, db_session=db_session)
db_session.add(incident)
db_session.commit()
@@ -72,7 +122,7 @@ def create_conversation(incident: Incident, db_session: SessionLocal):
incident_id=incident.id,
)
- return conversation
+ return incident.conversation
def archive_conversation(incident: Incident, db_session: SessionLocal):
@@ -262,8 +312,43 @@ def add_conversation_bookmarks(incident: Incident, db_session: SessionLocal):
log.exception(e)
-def add_participants(incident: Incident, participant_emails: List[str], db_session: SessionLocal):
- """Adds one or more participants to the conversation."""
+def add_case_participants(case: Case, participant_emails: List[str], db_session: SessionLocal):
+ """Adds one or more participants to the case conversation."""
+ if not case.conversation:
+ log.warning(
+ "Case participant(s) not added to conversation. No conversation available for this case."
+ )
+ return
+
+ plugin = plugin_service.get_active_instance(
+ db_session=db_session, project_id=case.project.id, plugin_type="conversation"
+ )
+ if not plugin:
+ log.warning(
+ "Case participant(s) not added to conversation. No conversation plugin enabled."
+ )
+ return
+
+ try:
+ plugin.instance.add_to_thread(
+ case.conversation.channel_id,
+ case.conversation.thread_id,
+ participant_emails,
+ )
+ except Exception as e:
+ event_service.log_case_event(
+ db_session=db_session,
+ source="Dispatch Core App",
+ description=f"Adding participant(s) to case conversation failed. Reason: {e}",
+ case_id=case.id,
+ )
+ log.exception(e)
+
+
+def add_incident_participants(
+ incident: Incident, participant_emails: List[str], db_session: SessionLocal
+):
+ """Adds one or more participants to the incident conversation."""
if not incident.conversation:
log.warning(
"Incident participant(s) not added to conversation. No conversation available for this incident."
diff --git a/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py b/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py
new file mode 100644
index 000000000000..c124e41afa3c
--- /dev/null
+++ b/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py
@@ -0,0 +1,28 @@
+"""Adds last_mfa_time to DispatchUser
+
+Revision ID: 5c60513d6e5e
+Revises: 3dd4d12844dc
+Create Date: 2023-09-27 15:17:00.450716
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "5c60513d6e5e"
+down_revision = "3dd4d12844dc"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column("dispatch_user", sa.Column("experimental_features", sa.Boolean(), default=False))
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("dispatch_user", "experimental_features")
+ # ### end Alembic commands ###
diff --git a/src/dispatch/database/service.py b/src/dispatch/database/service.py
index 03a3c8c39d7a..e404b96a7d7b 100644
--- a/src/dispatch/database/service.py
+++ b/src/dispatch/database/service.py
@@ -504,12 +504,15 @@ def search_filter_sort_paginate(
sort = False if sort_by else True
query = search(query_str=query_str, query=query, model=model, sort=sort)
- query = apply_model_specific_filters(model_cls, query, current_user, role)
+ query_restricted = apply_model_specific_filters(model_cls, query, current_user, role)
if filter_spec:
query = apply_filter_specific_joins(model_cls, filter_spec, query)
query = apply_filters(query, filter_spec, model_cls)
+ if model == "Incident":
+ query = query.intersect(query_restricted)
+
if sort_by:
sort_spec = create_sort_spec(model, sort_by, descending)
query = apply_sort(query, sort_spec)
diff --git a/src/dispatch/feedback/service/models.py b/src/dispatch/feedback/service/models.py
index e1d09e9f51ca..e2773f347bac 100644
--- a/src/dispatch/feedback/service/models.py
+++ b/src/dispatch/feedback/service/models.py
@@ -5,10 +5,11 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy_utils import TSVectorType
+from sqlalchemy.orm import relationship
from dispatch.database.core import Base
-from dispatch.individual.models import IndividualContactRead
-from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey
+from dispatch.individual.models import IndividualContactReadMinimal
+from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey, Pagination
from dispatch.project.models import ProjectRead
from .enums import ServiceFeedbackRating
@@ -26,6 +27,7 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base):
# Relationships
individual_contact_id = Column(Integer, ForeignKey("individual_contact.id"))
+ individual = relationship("IndividualContact")
search_vector = Column(
TSVectorType(
@@ -37,14 +39,14 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base):
@hybrid_property
def project(self):
- return self.service.project
+ return self.individual.project
# Pydantic models
class ServiceFeedbackBase(DispatchBase):
feedback: Optional[str] = Field(None, nullable=True)
hours: Optional[int]
- individual: Optional[IndividualContactRead]
+ individual: Optional[IndividualContactReadMinimal]
rating: ServiceFeedbackRating = ServiceFeedbackRating.little_effort
schedule: Optional[str]
shift_end_at: Optional[datetime]
@@ -64,6 +66,6 @@ class ServiceFeedbackRead(ServiceFeedbackBase):
project: Optional[ProjectRead]
-class ServiceFeedbackPagination(DispatchBase):
+class ServiceFeedbackPagination(Pagination):
items: List[ServiceFeedbackRead]
total: int
diff --git a/src/dispatch/feedback/service/reminder/service.py b/src/dispatch/feedback/service/reminder/service.py
index ae264e1d403d..248f729e0f73 100644
--- a/src/dispatch/feedback/service/reminder/service.py
+++ b/src/dispatch/feedback/service/reminder/service.py
@@ -3,7 +3,6 @@
from .models import (
ServiceFeedbackReminder,
- ServiceFeedbackReminderCreate,
ServiceFeedbackReminderUpdate,
)
from dispatch.individual.models import IndividualContact
@@ -24,11 +23,11 @@ def get_all_expired_reminders_by_project_id(
)
-def create(*, db_session, reminder_in: ServiceFeedbackReminderCreate) -> ServiceFeedbackReminder:
+def create(*, db_session, reminder_in: ServiceFeedbackReminder) -> ServiceFeedbackReminder:
"""Creates a new service feedback reminder."""
reminder = ServiceFeedbackReminder(**reminder_in.dict())
- db_session.add(reminder_in)
+ db_session.add(reminder)
db_session.commit()
return reminder
@@ -55,5 +54,6 @@ def delete(*, db_session, reminder_id: int):
.filter(ServiceFeedbackReminder.id == reminder_id)
.one_or_none()
)
- db_session.delete(reminder)
- db_session.commit()
+ if reminder:
+ db_session.delete(reminder)
+ db_session.commit()
diff --git a/src/dispatch/feedback/service/scheduled.py b/src/dispatch/feedback/service/scheduled.py
index 6f5968659559..3c7a3c48f079 100644
--- a/src/dispatch/feedback/service/scheduled.py
+++ b/src/dispatch/feedback/service/scheduled.py
@@ -28,7 +28,7 @@
@timer
@scheduled_project_task
def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project):
- oncall_shift_feedback(db_session=db_session, project=project)
+ oncall_shift_feedback(db_session=db_session, project=project, hour=16)
find_expired_reminders_and_send(db_session=db_session, project=project)
@@ -36,7 +36,7 @@ def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project):
@timer
@scheduled_project_task
def oncall_shift_feedback_emea(db_session: SessionLocal, project: Project):
- oncall_shift_feedback(db_session=db_session, project=project)
+ oncall_shift_feedback(db_session=db_session, project=project, hour=6)
find_expired_reminders_and_send(db_session=db_session, project=project)
@@ -60,13 +60,18 @@ def find_expired_reminders_and_send(*, db_session: SessionLocal, project: Projec
def find_schedule_and_send(
- *, db_session: SessionLocal, project: Project, oncall_plugin: PluginInstance, schedule_id: str
+ *,
+ db_session: SessionLocal,
+ project: Project,
+ oncall_plugin: PluginInstance,
+ schedule_id: str,
+ hour: int,
):
"""
Given PagerDuty schedule_id, determine if the shift ended for the previous oncall person and
send the health metrics feedback request
"""
- current_oncall = oncall_plugin.instance.did_oncall_just_go_off_shift(schedule_id)
+ current_oncall = oncall_plugin.instance.did_oncall_just_go_off_shift(schedule_id, hour)
individual = individual_service.get_by_email_and_project(
db_session=db_session, email=current_oncall["email"], project_id=project.id
@@ -82,7 +87,7 @@ def find_schedule_and_send(
)
-def oncall_shift_feedback(db_session: SessionLocal, project: Project):
+def oncall_shift_feedback(db_session: SessionLocal, project: Project, hour: int):
"""
Experimental: collects feedback from individuals participating in an oncall service that has health metrics enabled
when their oncall shift ends. For now, only for one project and schedule.
@@ -115,4 +120,5 @@ def oncall_shift_feedback(db_session: SessionLocal, project: Project):
project=project,
oncall_plugin=oncall_plugin,
schedule_id=schedule_id,
+ hour=hour,
)
diff --git a/src/dispatch/feedback/service/views.py b/src/dispatch/feedback/service/views.py
new file mode 100644
index 000000000000..280ec390ac5e
--- /dev/null
+++ b/src/dispatch/feedback/service/views.py
@@ -0,0 +1,14 @@
+from fastapi import APIRouter
+
+from dispatch.database.service import search_filter_sort_paginate, CommonParameters
+
+from .models import ServiceFeedbackPagination
+
+
+router = APIRouter()
+
+
+@router.get("", response_model=ServiceFeedbackPagination)
+def get_feedback_entries(commons: CommonParameters):
+ """Get all feedback entries, or only those matching a given search term."""
+ return search_filter_sort_paginate(model="ServiceFeedback", **commons)
diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py
index 84f553c20456..4585f5f5d4d6 100644
--- a/src/dispatch/incident/flows.py
+++ b/src/dispatch/incident/flows.py
@@ -140,68 +140,74 @@ def inactivate_incident_participants(incident: Incident, db_session: Session):
)
-@background_task
-def incident_create_flow(*, organization_slug: str, incident_id: int, db_session=None) -> Incident:
- """Creates all resources required for new incidents."""
- # we get the incident
- incident = incident_service.get(db_session=db_session, incident_id=incident_id)
-
+def incident_create_resources(*, incident: Incident, db_session=None) -> Incident:
+ """Creates all resources required for incidents."""
# we create the incident ticket
- ticket_flows.create_incident_ticket(incident=incident, db_session=db_session)
+ if not incident.ticket:
+ ticket_flows.create_incident_ticket(incident=incident, db_session=db_session)
# we resolve individual and team participants
individual_participants, team_participants = get_incident_participants(incident, db_session)
+ tactical_participant_emails = [i.email for i, _ in individual_participants]
# we create the tactical group
- tactical_participant_emails = [i.email for i, _ in individual_participants]
- tactical_group = group_flows.create_group(
- subject=incident,
- group_type=GroupType.tactical,
- group_participants=tactical_participant_emails,
- db_session=db_session,
- )
+ if not incident.tactical_group:
+ group_flows.create_group(
+ subject=incident,
+ group_type=GroupType.tactical,
+ group_participants=tactical_participant_emails,
+ db_session=db_session,
+ )
# we create the notifications group
- notification_participant_emails = [t.email for t in team_participants]
- notifications_group = group_flows.create_group(
- subject=incident,
- group_type=GroupType.notifications,
- group_participants=notification_participant_emails,
- db_session=db_session,
- )
+ if not incident.notifications_group:
+ notification_participant_emails = [t.email for t in team_participants]
+ group_flows.create_group(
+ subject=incident,
+ group_type=GroupType.notifications,
+ group_participants=notification_participant_emails,
+ db_session=db_session,
+ )
# we create the storage folder
- storage_members = []
- if tactical_group and notifications_group:
- storage_members = [tactical_group.email, notifications_group.email]
- else:
- storage_members = tactical_participant_emails
+ if not incident.storage:
+ storage_members = []
+ if incident.tactical_group and incident.notifications_group:
+ storage_members = [incident.tactical_group.email, incident.notifications_group.email]
+ else:
+ storage_members = tactical_participant_emails
- storage_flows.create_storage(
- subject=incident, storage_members=storage_members, db_session=db_session
- )
+ storage_flows.create_storage(
+ subject=incident, storage_members=storage_members, db_session=db_session
+ )
# we create the incident document
- document_flows.create_document(
- subject=incident,
- document_type=DocumentResourceTypes.incident,
- document_template=incident.incident_type.incident_template_document,
- db_session=db_session,
- )
+ if not incident.incident_document:
+ document_flows.create_document(
+ subject=incident,
+ document_type=DocumentResourceTypes.incident,
+ document_template=incident.incident_type.incident_template_document,
+ db_session=db_session,
+ )
# we create the conference room
- conference_participants = []
- if tactical_group and notifications_group:
- conference_participants = [tactical_group.email, notifications_group.email]
- else:
- conference_participants = tactical_participant_emails
+ if not incident.conference:
+ conference_participants = []
+ if incident.tactical_group and incident.notifications_group:
+ conference_participants = [
+ incident.tactical_group.email,
+ incident.notifications_group.email,
+ ]
+ else:
+ conference_participants = tactical_participant_emails
- conference_flows.create_conference(
- incident=incident, participants=conference_participants, db_session=db_session
- )
+ conference_flows.create_conference(
+ incident=incident, participants=conference_participants, db_session=db_session
+ )
# we create the conversation
- conversation_flows.create_conversation(incident=incident, db_session=db_session)
+ if not incident.conversation:
+ conversation_flows.create_incident_conversation(incident=incident, db_session=db_session)
# we update the incident ticket
ticket_flows.update_incident_ticket(incident_id=incident.id, db_session=db_session)
@@ -238,7 +244,7 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session
)
# we add the participant to the conversation
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
incident=incident, participant_emails=[user_email], db_session=db_session
)
@@ -272,14 +278,31 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session
incident_id=incident.id,
)
- send_incident_created_notifications(incident, db_session)
+ return incident
- event_service.log_incident_event(
- db_session=db_session,
- source="Dispatch Core App",
- description="Incident notifications sent",
- incident_id=incident.id,
- )
+
+@background_task
+def incident_create_resources_flow(
+ *, organization_slug: str, incident_id: int, db_session=None
+) -> Incident:
+ """Creates all resources required for an existing incident."""
+ # we get the incident
+ incident = incident_service.get(db_session=db_session, incident_id=incident_id)
+
+ # we create the incident resources
+ return incident_create_resources(incident=incident, db_session=db_session)
+
+
+@background_task
+def incident_create_flow(*, organization_slug: str, incident_id: int, db_session=None) -> Incident:
+ """Creates all resources required for new incidents and initiates incident response workflow."""
+ # we get the incident
+ incident = incident_service.get(db_session=db_session, incident_id=incident_id)
+
+ # we create the incident resources
+ incident_create_resources(incident=incident, db_session=db_session)
+
+ send_incident_created_notifications(incident, db_session)
# we page the incident commander based on incident priority
if incident.incident_priority.page_commander:
@@ -902,7 +925,7 @@ def incident_add_or_reactivate_participant_flow(
if incident.status != IncidentStatus.closed:
# we add the participant to the conversation
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
incident=incident, participant_emails=[user_email], db_session=db_session
)
@@ -942,7 +965,7 @@ def incident_remove_participant_flow(
for assignee in task.assignees:
if assignee == participant:
# we add the participant to the conversation
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
incident=incident, participant_emails=[user_email], db_session=db_session
)
@@ -954,7 +977,7 @@ def incident_remove_participant_flow(
if user_email == incident.commander.individual.email:
# we add the participant to the conversation
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
incident=incident, participant_emails=[user_email], db_session=db_session
)
diff --git a/src/dispatch/incident/messaging.py b/src/dispatch/incident/messaging.py
index 1c6f1d4b2575..994ae45d4564 100644
--- a/src/dispatch/incident/messaging.py
+++ b/src/dispatch/incident/messaging.py
@@ -11,6 +11,7 @@
from dispatch.conversation.enums import ConversationCommands
from dispatch.database.core import SessionLocal, resolve_attr
from dispatch.document import service as document_service
+from dispatch.event import service as event_service
from dispatch.incident.enums import IncidentStatus
from dispatch.incident.models import Incident, IncidentRead
from dispatch.notification import service as notification_service
@@ -347,6 +348,13 @@ def send_incident_created_notifications(incident: Incident, db_session: SessionL
notification_params=notification_params,
)
+ event_service.log_incident_event(
+ db_session=db_session,
+ source="Dispatch Core App",
+ description="Incident notifications sent",
+ incident_id=incident.id,
+ )
+
log.debug("Incident created notifications sent.")
diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py
index 031909017f47..f8c740a70230 100644
--- a/src/dispatch/incident/views.py
+++ b/src/dispatch/incident/views.py
@@ -36,6 +36,7 @@
incident_delete_flow,
incident_subscribe_participant_flow,
incident_update_flow,
+ incident_create_resources_flow,
)
from .metrics import create_incident_metric_query, make_forecast
from .models import (
@@ -139,6 +140,25 @@ def create_incident(
return incident
+@router.post(
+ "/{incident_id}/resources",
+ response_model=IncidentRead,
+ summary="Creates resources for an existing incident.",
+)
+def create_incident_resources(
+ organization: OrganizationSlug,
+ incident_id: PrimaryKey,
+ current_incident: CurrentIncident,
+ background_tasks: BackgroundTasks,
+):
+ """Creates resources for an existing incident."""
+ background_tasks.add_task(
+ incident_create_resources_flow, organization_slug=organization, incident_id=incident_id
+ )
+
+ return current_incident
+
+
@router.put(
"/{incident_id}",
response_model=IncidentRead,
diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py
index 0facbef81ce4..877dac1ce69b 100644
--- a/src/dispatch/messaging/strings.py
+++ b/src/dispatch/messaging/strings.py
@@ -5,6 +5,7 @@
from dispatch.messaging.email.filters import env
from dispatch.conversation.enums import ConversationButtonActions
from dispatch.incident.enums import IncidentStatus
+from dispatch.case.enums import CaseStatus
from dispatch.enums import Visibility
from dispatch import config
@@ -34,6 +35,7 @@ class MessageType(DispatchEnum):
incident_tactical_report = "incident-tactical-report"
incident_task_list = "incident-task-list"
incident_task_reminder = "incident-task-reminder"
+ case_status_reminder = "case-status-reminder"
service_feedback = "service-feedback"
@@ -43,6 +45,13 @@ class MessageType(DispatchEnum):
IncidentStatus.closed: "This no longer requires additional involvement, long term incident action items have been assigned to their respective owners.",
}
+CASE_STATUS_DESCRIPTIONS = {
+ CaseStatus.new: "This case is new and needs triaging.",
+ CaseStatus.triage: "This case is being triaged.",
+ CaseStatus.escalated: "This case has been escalated.",
+ CaseStatus.closed: "This case has been closed.",
+}
+
INCIDENT_VISIBILITY_DESCRIPTIONS = {
Visibility.open: "We ask that you use your best judgment while sharing details about this incident outside of the dedicated channels of communication. Please reach out to the Incident Commander if you have any questions.",
Visibility.restricted: "This incident is restricted to immediate participants of this incident. We ask that you exercise extra caution and discretion while talking about this incident outside of the dedicated channels of communication. Only invite new participants that are strictly necessary. Please reach out to the Incident Commander if you have any questions.",
@@ -236,6 +245,16 @@ class MessageType(DispatchEnum):
"\n", " "
).strip()
+CASE_TRIAGE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently.
+Please ensure you triage the case based on its priority.""".replace(
+ "\n", " "
+).strip()
+
+CASE_CLOSE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently.
+You can use the case 'Resolve' button if it has been resolved and can be closed.""".replace(
+ "\n", " "
+).strip()
+
INCIDENT_TASK_NEW_DESCRIPTION = """
The following incident task has been created and assigned to you by {{task_creator}}: {{task_description}}"""
@@ -390,6 +409,14 @@ class MessageType(DispatchEnum):
INCIDENT_TITLE = {"title": "Title", "text": "{{title}}"}
+CASE_TITLE = {"title": "Title", "text": "{{title}}"}
+
+CASE_STATUS = {
+ "title": "Status - {{status}}",
+ "status_mapping": CASE_STATUS_DESCRIPTIONS,
+}
+
+
if config.DISPATCH_MARKDOWN_IN_INCIDENT_DESC:
INCIDENT_DESCRIPTION = {"title": "Description", "text": "{{description | markdown}}"}
else:
@@ -596,6 +623,28 @@ class MessageType(DispatchEnum):
INCIDENT_STATUS,
]
+
+CASE_CLOSE_REMINDER = [
+ {
+ "title": "{{name}} Case - Close Reminder",
+ "title_link": "{{ticket_weblink}}",
+ "text": CASE_CLOSE_REMINDER_DESCRIPTION,
+ },
+ CASE_TITLE,
+ CASE_STATUS,
+]
+
+CASE_TRIAGE_REMINDER = [
+ {
+ "title": "{{name}} Case - Triage Reminder",
+ "title_link": "{{ticket_weblink}}",
+ "text": CASE_TRIAGE_REMINDER_DESCRIPTION,
+ },
+ CASE_TITLE,
+ CASE_STATUS,
+]
+
+
INCIDENT_TASK_REMINDER = [
{"title": "Incident - {{ name }}", "text": "{{ title }}"},
{"title": "Creator", "text": "{{ creator }}"},
diff --git a/src/dispatch/plugins/dispatch_pagerduty/plugin.py b/src/dispatch/plugins/dispatch_pagerduty/plugin.py
index 1e7303d8bb32..b30b0456bdd2 100644
--- a/src/dispatch/plugins/dispatch_pagerduty/plugin.py
+++ b/src/dispatch/plugins/dispatch_pagerduty/plugin.py
@@ -83,12 +83,13 @@ def page(
incident_description=incident_description,
)
- def did_oncall_just_go_off_shift(self, schedule_id: str) -> Optional[dict]:
+ def did_oncall_just_go_off_shift(self, schedule_id: str, hour: int) -> Optional[dict]:
client = APISession(self.configuration.api_key.get_secret_value())
client.url = self.configuration.pagerduty_api_url
return oncall_shift_check(
client=client,
schedule_id=schedule_id,
+ hour=hour,
)
def get_schedule_id_from_service_id(self, service_id: str) -> Optional[str]:
diff --git a/src/dispatch/plugins/dispatch_pagerduty/service.py b/src/dispatch/plugins/dispatch_pagerduty/service.py
index 130401e841e2..89079192ff40 100644
--- a/src/dispatch/plugins/dispatch_pagerduty/service.py
+++ b/src/dispatch/plugins/dispatch_pagerduty/service.py
@@ -177,9 +177,12 @@ def get_oncall_at_time(client: APISession, schedule_id: str, utctime: str) -> Op
raise e
-def oncall_shift_check(client: APISession, schedule_id: str) -> Optional[dict]:
+def oncall_shift_check(client: APISession, schedule_id: str, hour: int) -> Optional[dict]:
"""Determines whether the oncall person just went off shift and returns their email."""
now = datetime.utcnow()
+ # in case scheduler is late, replace hour with exact one for shift comparison
+ now = now.replace(hour=hour, minute=0, second=0, microsecond=0)
+
# compare oncall person scheduled 18 hours ago vs 2 hours from now
previous_shift = (now - timedelta(hours=18)).isoformat(timespec="minutes") + "Z"
next_shift = (now + timedelta(hours=2)).isoformat(timespec="minutes") + "Z"
diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py
index e4797c36d368..a49ad567497b 100644
--- a/src/dispatch/plugins/dispatch_slack/case/interactive.py
+++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py
@@ -1032,7 +1032,7 @@ def handle_escalation_submission_event(
case=case, organization_slug=context["subject"].organization_slug, db_session=db_session
)
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
incident=incident, participant_emails=[user.email], db_session=db_session
)
@@ -1082,7 +1082,7 @@ def join_incident_button_click(
case = case_service.get(db_session=db_session, case_id=context["subject"].id)
# we add the user to the incident conversation
- conversation_flows.add_participants(
+ conversation_flows.add_incident_participants(
# TODO: handle case where there are multiple related incidents
incident=case.incidents[0],
participant_emails=[user.email],
diff --git a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py
index f0ba81afdb54..ea61d7665323 100644
--- a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py
+++ b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py
@@ -186,7 +186,7 @@ def handle_incident_feedback_submission_event(
ack_incident_feedback_submission_event(ack=ack)
incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id)
- feedback = form_data.get(IncidentFeedbackNotificationBlockIds.feedback_input)
+ feedback = form_data.get(IncidentFeedbackNotificationBlockIds.feedback_input, "")
rating = form_data.get(IncidentFeedbackNotificationBlockIds.rating_select, {}).get("value")
feedback_in = FeedbackCreate(
@@ -276,6 +276,7 @@ def oncall_shift_feedback_input(
multiline=True,
placeholder="How would you describe your experience?",
),
+ optional=True,
label=label,
**kwargs,
)
@@ -381,7 +382,7 @@ def handle_oncall_shift_feedback_submission_event(
ack_oncall_shift_feedback_submission_event(ack=ack)
- feedback = form_data.get(ServiceFeedbackNotificationBlockIds.feedback_input)
+ 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|reminder_id
@@ -447,7 +448,7 @@ def handle_oncall_shift_feedback_submission_event(
]
try:
plugin.instance.send_direct(
- individual.email,
+ user.email,
notification_text,
notification_template,
MessageType.service_feedback,
diff --git a/src/dispatch/static/dispatch/src/auth/store.js b/src/dispatch/static/dispatch/src/auth/store.js
index 1bf94015d960..b8cafad588ee 100644
--- a/src/dispatch/static/dispatch/src/auth/store.js
+++ b/src/dispatch/static/dispatch/src/auth/store.js
@@ -25,6 +25,7 @@ const state = {
email: "",
projects: [],
role: null,
+ experimental_features: false,
},
selected: {
...getDefaultSelectedState(),
@@ -148,6 +149,15 @@ const actions = {
commit("SET_USER_LOGOUT")
router.go()
},
+ getExperimentalFeatures({ commit }) {
+ UserApi.getUserInfo()
+ .then((response) => {
+ commit("SET_EXPERIMENTAL_FEATURES", response.data.experimental_features)
+ })
+ .catch((error) => {
+ console.error("Error occurred while updating experimental features: ", error)
+ })
+ },
createExpirationCheck({ state, commit }) {
// expiration time minus 10 min
let expire_at = subMinutes(fromUnixTime(state.currentUser.exp), 10)
@@ -195,6 +205,9 @@ const mutations = {
}
localStorage.setItem("token", token)
},
+ SET_EXPERIMENTAL_FEATURES(state, value) {
+ state.currentUser.experimental_features = value
+ },
SET_USER_LOGOUT(state) {
state.currentUser = { loggedIn: false }
},
diff --git a/src/dispatch/static/dispatch/src/case/ResourcesTab.vue b/src/dispatch/static/dispatch/src/case/ResourcesTab.vue
index ad7403449c82..a9eb2b5d5df4 100644
--- a/src/dispatch/static/dispatch/src/case/ResourcesTab.vue
+++ b/src/dispatch/static/dispatch/src/case/ResourcesTab.vue
@@ -65,23 +65,83 @@
+
+
+
+ Recreate Missing Resources
+ Initiate a retry for creating any missing or unsuccesfully created
+ resource(s).
+
+
+
+ refresh
+
+
+
+
+
+ Creating resources...
+ Initiate a retry for creating any missing or unsuccesfully created
+ resource(s).
+
+
+
diff --git a/src/dispatch/static/dispatch/src/case/api.js b/src/dispatch/static/dispatch/src/case/api.js
index 8a3b0d2abb61..2fc1a01f27e3 100644
--- a/src/dispatch/static/dispatch/src/case/api.js
+++ b/src/dispatch/static/dispatch/src/case/api.js
@@ -44,4 +44,8 @@ export default {
delete(caseId) {
return API.delete(`/${resource}/${caseId}`)
},
+
+ createAllResources(caseId, payload) {
+ return API.post(`/${resource}/${caseId}/resources`, payload)
+ },
}
diff --git a/src/dispatch/static/dispatch/src/case/store.js b/src/dispatch/static/dispatch/src/case/store.js
index bbe3ec901dac..6eda8cc9b416 100644
--- a/src/dispatch/static/dispatch/src/case/store.js
+++ b/src/dispatch/static/dispatch/src/case/store.js
@@ -3,6 +3,7 @@ import { debounce } from "lodash"
import SearchUtils from "@/search/utils"
import CaseApi from "@/case/api"
+import PluginApi from "@/plugin/api"
import router from "@/router"
const getDefaultSelectedState = () => {
@@ -253,6 +254,40 @@ const actions = {
commit("SET_SELECTED_LOADING", false)
})
},
+ createAllResources({ commit, dispatch }) {
+ commit("SET_SELECTED_LOADING", true)
+ return CaseApi.createAllResources(state.selected.id)
+ .then(() => {
+ CaseApi.get(state.selected.id).then((response) => {
+ commit("SET_SELECTED", response.data)
+ dispatch("getEnabledPlugins").then((enabledPlugins) => {
+ // Poll the server for resource creation updates.
+ var interval = setInterval(function () {
+ if (
+ state.selected.conversation ^ enabledPlugins.includes("conversation") ||
+ state.selected.documents ^ enabledPlugins.includes("document") ||
+ state.selected.storage ^ enabledPlugins.includes("storage") ||
+ state.selected.groups ^ enabledPlugins.includes("participant-group") ||
+ state.selected.ticket ^ enabledPlugins.includes("ticket")
+ ) {
+ dispatch("get").then(() => {
+ clearInterval(interval)
+ commit("SET_SELECTED_LOADING", false)
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Resources(s) created successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ }
+ }, 5000)
+ })
+ })
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ },
save({ commit, dispatch }) {
commit("SET_SELECTED_LOADING", true)
if (!state.selected.id) {
@@ -330,6 +365,37 @@ const actions = {
)
})
},
+ getEnabledPlugins() {
+ if (!state.selected.project) {
+ return false
+ }
+ return PluginApi.getAllInstances({
+ filter: JSON.stringify({
+ and: [
+ {
+ model: "PluginInstance",
+ field: "enabled",
+ op: "==",
+ value: "true",
+ },
+ {
+ model: "Project",
+ field: "name",
+ op: "==",
+ value: state.selected.project.name,
+ },
+ ],
+ }),
+ itemsPerPage: 50,
+ }).then((response) => {
+ return response.data.items.reduce((result, item) => {
+ if (item.plugin) {
+ result.push(item.plugin.type)
+ }
+ return result
+ }, [])
+ })
+ },
}
const mutations = {
diff --git a/src/dispatch/static/dispatch/src/components/AppToolbar.vue b/src/dispatch/static/dispatch/src/components/AppToolbar.vue
index f98c0e80d440..78aaad9c1a61 100644
--- a/src/dispatch/static/dispatch/src/components/AppToolbar.vue
+++ b/src/dispatch/static/dispatch/src/components/AppToolbar.vue
@@ -112,6 +112,16 @@
+ Experimental Features
+
+
Organizations
@@ -159,6 +169,7 @@ import { mapActions, mapGetters, mapMutations, mapState } from "vuex"
import Util from "@/util"
import OrganizationApi from "@/organization/api"
import OrganizationCreateEditDialog from "@/organization/CreateEditDialog.vue"
+import UserApi from "@/auth/api"
export default {
name: "AppToolbar",
@@ -181,6 +192,16 @@ export default {
},
},
methods: {
+ updateExperimentalFeatures() {
+ UserApi.getUserInfo()
+ .then((response) => {
+ let userId = response.data.id
+ UserApi.update(userId, { id: userId, experimental_features: this.experimental_features })
+ })
+ .catch((error) => {
+ console.error("Error occurred while updating experimental features: ", error)
+ })
+ },
handleDrawerToggle() {
this.$store.dispatch("app/toggleDrawer")
},
@@ -203,7 +224,7 @@ export default {
},
...mapState("auth", ["currentUser"]),
...mapState("app", ["currentVersion"]),
- ...mapActions("auth", ["logout"]),
+ ...mapActions("auth", ["logout", "getExperimentalFeatures"]),
...mapActions("search", ["setQuery"]),
...mapActions("organization", ["showCreateEditDialog"]),
...mapActions("app", ["showCommitMessage"]),
@@ -233,6 +254,8 @@ export default {
this.organizations = response.data.items
this.loading = false
})
+
+ this.getExperimentalFeatures()
},
}
diff --git a/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue b/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue
index 4c9bc4394fd8..28532489bf0a 100644
--- a/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue
+++ b/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue
@@ -45,6 +45,28 @@
+
+
+
+ Incident Participant
+ Show only incidents with this participant
+
+
+
+
+
@@ -70,6 +92,7 @@ import ProjectCombobox from "@/project/ProjectCombobox.vue"
import RouterUtils from "@/router/utils"
import SearchUtils from "@/search/utils"
import TagFilterAutoComplete from "@/tag/TagFilterAutoComplete.vue"
+import ParticipantSelect from "@/incident/ParticipantSelect.vue"
let today = function () {
let now = new Date()
@@ -86,6 +109,7 @@ export default {
IncidentTypeCombobox,
ProjectCombobox,
TagFilterAutoComplete,
+ ParticipantSelect,
},
props: {
@@ -118,6 +142,8 @@ export default {
end: null,
},
},
+ local_participant_is_commander: false,
+ local_participant: null,
}
},
@@ -130,6 +156,7 @@ export default {
this.filters.project.length,
this.filters.status.length,
this.filters.tag.length,
+ this.local_participant == null ? 0 : 1,
1,
])
},
@@ -138,6 +165,15 @@ export default {
methods: {
applyFilters() {
+ if (this.local_participant) {
+ if (this.local_participant_is_commander) {
+ this.filters.commander = this.local_participant
+ this.filters.participant = null
+ } else {
+ this.filters.commander = null
+ this.filters.participant = this.local_participant
+ }
+ }
RouterUtils.updateURLFilters(this.filters)
this.fetchData()
// we close the dialog
diff --git a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue
index 9c1a3d2c9f15..b478fb9ecb6e 100644
--- a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue
+++ b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue
@@ -62,23 +62,83 @@
+
+
+
+ Recreate Missing Resources
+ Initiate a retry for creating any missing or unsuccesfully created
+ resource(s).
+
+
+
+ refresh
+
+
+
+
+
+ Creating resources...
+ Initiate a retry for creating any missing or unsuccesfully created
+ resource(s).
+
+
+
diff --git a/src/dispatch/static/dispatch/src/incident/api.js b/src/dispatch/static/dispatch/src/incident/api.js
index 0516aaa84cdf..1f77a9053460 100644
--- a/src/dispatch/static/dispatch/src/incident/api.js
+++ b/src/dispatch/static/dispatch/src/incident/api.js
@@ -58,4 +58,8 @@ export default {
createReport(incidentId, type, payload) {
return API.post(`/${resource}/${incidentId}/report/${type}`, payload)
},
+
+ createAllResources(incidentId, payload) {
+ return API.post(`/${resource}/${incidentId}/resources`, payload)
+ },
}
diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js
index bf57d004f858..f43befd9717d 100644
--- a/src/dispatch/static/dispatch/src/incident/store.js
+++ b/src/dispatch/static/dispatch/src/incident/store.js
@@ -3,6 +3,7 @@ import { debounce } from "lodash"
import SearchUtils from "@/search/utils"
import IncidentApi from "@/incident/api"
+import PluginApi from "@/plugin/api"
import router from "@/router"
const getDefaultSelectedState = () => {
@@ -347,6 +348,40 @@ const actions = {
)
})
},
+ createAllResources({ commit, dispatch }) {
+ commit("SET_SELECTED_LOADING", true)
+ return IncidentApi.createAllResources(state.selected.id)
+ .then(() => {
+ IncidentApi.get(state.selected.id).then((response) => {
+ commit("SET_SELECTED", response.data)
+ dispatch("getEnabledPlugins").then((enabledPlugins) => {
+ // Poll the server for resource creation updates.
+ var interval = setInterval(function () {
+ if (
+ state.selected.conversation ^ enabledPlugins.includes("conversation") ||
+ state.selected.documents ^ enabledPlugins.includes("document") ||
+ state.selected.storage ^ enabledPlugins.includes("storage") ||
+ state.selected.conference ^ enabledPlugins.includes("conference") ||
+ state.selected.ticket ^ enabledPlugins.includes("ticket")
+ ) {
+ dispatch("get").then(() => {
+ clearInterval(interval)
+ commit("SET_SELECTED_LOADING", false)
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Resources(s) created successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ }
+ }, 5000)
+ })
+ })
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ },
resetSelected({ commit }) {
commit("RESET_SELECTED")
},
@@ -371,6 +406,37 @@ const actions = {
)
})
},
+ getEnabledPlugins() {
+ if (!state.selected.project) {
+ return false
+ }
+ return PluginApi.getAllInstances({
+ filter: JSON.stringify({
+ and: [
+ {
+ model: "PluginInstance",
+ field: "enabled",
+ op: "==",
+ value: "true",
+ },
+ {
+ model: "Project",
+ field: "name",
+ op: "==",
+ value: state.selected.project.name,
+ },
+ ],
+ }),
+ itemsPerPage: 50,
+ }).then((response) => {
+ return response.data.items.reduce((result, item) => {
+ if (item.plugin) {
+ result.push(item.plugin.type)
+ }
+ return result
+ }, [])
+ })
+ },
}
const mutations = {
diff --git a/src/dispatch/static/dispatch/src/router/utils.js b/src/dispatch/static/dispatch/src/router/utils.js
index 081d8b8f64e1..ccd5c72c20ef 100644
--- a/src/dispatch/static/dispatch/src/router/utils.js
+++ b/src/dispatch/static/dispatch/src/router/utils.js
@@ -14,17 +14,33 @@ export default {
return
}
each(value, function (item) {
- if (has(flatFilters, key)) {
- if (typeof item === "string" || item instanceof String) {
- flatFilters[key].push(item)
+ if (["commander", "participant"].includes(key)) {
+ if (has(flatFilters, key)) {
+ if (typeof item === "string" || item instanceof String) {
+ flatFilters[key].push(item)
+ } else {
+ flatFilters[key].push(item.email)
+ }
} else {
- flatFilters[key].push(item.name)
+ if (typeof item === "string" || item instanceof String) {
+ flatFilters[key] = [item]
+ } else {
+ flatFilters[key] = [item.email]
+ }
}
} else {
- if (typeof item === "string" || item instanceof String) {
- flatFilters[key] = [item]
+ if (has(flatFilters, key)) {
+ if (typeof item === "string" || item instanceof String) {
+ flatFilters[key].push(item)
+ } else {
+ flatFilters[key].push(item.name)
+ }
} else {
- flatFilters[key] = [item.name]
+ if (typeof item === "string" || item instanceof String) {
+ flatFilters[key] = [item]
+ } else {
+ flatFilters[key] = [item.name]
+ }
}
}
})
@@ -69,6 +85,24 @@ export default {
}
return
}
+ if (["commander", "participant"].includes(key)) {
+ if (typeof value === "string" || value instanceof String) {
+ if (has(filters, key)) {
+ filters[key].push({ email: value })
+ } else {
+ filters[key] = [{ email: value }]
+ }
+ } else {
+ each(value, function (item) {
+ if (has(filters, key)) {
+ filters[key].push({ email: item })
+ } else {
+ filters[key] = [{ email: item }]
+ }
+ })
+ }
+ return
+ }
if (typeof value === "string" || value instanceof String) {
if (has(filters, key)) {
filters[key].push({ name: value })
diff --git a/src/dispatch/static/dispatch/src/search/utils.js b/src/dispatch/static/dispatch/src/search/utils.js
index 3d1be804615f..20438a2c5183 100644
--- a/src/dispatch/static/dispatch/src/search/utils.js
+++ b/src/dispatch/static/dispatch/src/search/utils.js
@@ -118,7 +118,14 @@ export default {
if (!value) {
return
}
- if (has(value, "id")) {
+ if (["commander", "participant"].includes(key) && has(value, "email")) {
+ subFilter.push({
+ model: toPascalCase(key),
+ field: "email",
+ op: "==",
+ value: value.email,
+ })
+ } else if (has(value, "id")) {
subFilter.push({
model: toPascalCase(key),
field: "id",
diff --git a/src/dispatch/static/dispatch/src/service_feedback/api.js b/src/dispatch/static/dispatch/src/service_feedback/api.js
new file mode 100644
index 000000000000..95b15f247874
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/service_feedback/api.js
@@ -0,0 +1,9 @@
+import API from "@/api"
+
+const resource = "/service_feedback"
+
+export default {
+ getAll(options) {
+ return API.get(`${resource}`, { params: { ...options } })
+ },
+}