diff --git a/requirements-base.txt b/requirements-base.txt index e7f2d7377c99..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 @@ -122,7 +122,7 @@ frozenlist==1.4.0 # aiosignal google-api-core==2.11.1 # via google-api-python-client -google-api-python-client==2.100.0 +google-api-python-client==2.101.0 # via -r requirements-base.in google-auth==2.22.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 @@ -235,7 +235,7 @@ oauthlib[signedtoken]==3.2.2 # atlassian-python-api # jira # requests-oauthlib -openai==0.28.0 +openai==0.28.1 # via -r requirements-base.in packaging==23.1 # 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 d15fe4be7bbd..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 @@ -90,7 +90,7 @@ python-dateutil==2.8.2 # via faker pyyaml==6.0.1 # via pre-commit -ruff==0.0.290 +ruff==0.0.291 # via -r requirements-dev.in six==1.16.0 # via 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/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/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/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/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 7b397295661d..a174ff788a52 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -146,68 +146,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) @@ -244,7 +250,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 ) @@ -279,14 +285,31 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session type=EventType.participant_updated, ) - 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: @@ -917,7 +940,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 ) @@ -957,7 +980,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 ) @@ -969,7 +992,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 dacfa5f04b7c..1c95931a113a 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -39,6 +39,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 ( @@ -142,6 +143,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/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 307b837fa18b..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( @@ -275,8 +275,8 @@ def oncall_shift_feedback_input( initial_value=initial_value, multiline=True, placeholder="How would you describe your experience?", - optional=True, ), + optional=True, label=label, **kwargs, ) @@ -448,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/package-lock.json b/src/dispatch/static/dispatch/package-lock.json index 1be988761dd3..85d77f4e0cc1 100644 --- a/src/dispatch/static/dispatch/package-lock.json +++ b/src/dispatch/static/dispatch/package-lock.json @@ -584,9 +584,9 @@ "dev": true }, "node_modules/@eslint/js": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", - "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz", + "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -712,9 +712,9 @@ } }, "node_modules/@koumoul/vjsf": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/@koumoul/vjsf/-/vjsf-2.22.0.tgz", - "integrity": "sha512-qQ23G+OX8Vf16K3ysxb7axk5A+tREjVwCxBShvvlQ9NETNqifpBqhHcjwMtkS0kxf7f8yA+hMWnJczm57d+EYA==", + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/@koumoul/vjsf/-/vjsf-2.22.1.tgz", + "integrity": "sha512-u5yaNTzeCTNyhJlhM6zLGbf7EPQQgBR64kNF1b3EMZFcXTpmClewU1n+lg8Wj6f5RDPLwAwlVSNPMdgpfMD9jg==", "dependencies": { "debounce": "^1.2.1", "debounce-promise": "^3.1.2", @@ -816,12 +816,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.0.tgz", - "integrity": "sha512-xis/RXXsLxwThKnlIXouxmIvvT3zvQj1JE39GsNieMUrMpb3/GySHDh2j8itCG22qKVD4MYLBp7xB73cUW/UUw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", "dev": true, "dependencies": { - "playwright": "1.38.0" + "playwright": "1.38.1" }, "bin": { "playwright": "cli.js" @@ -2595,15 +2595,15 @@ } }, "node_modules/eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", - "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz", + "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.49.0", + "@eslint/js": "8.50.0", "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -4820,12 +4820,12 @@ } }, "node_modules/playwright": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.0.tgz", - "integrity": "sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", "dev": true, "dependencies": { - "playwright-core": "1.38.0" + "playwright-core": "1.38.1" }, "bin": { "playwright": "cli.js" @@ -4838,9 +4838,9 @@ } }, "node_modules/playwright-core": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.0.tgz", - "integrity": "sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -7053,9 +7053,9 @@ } }, "@eslint/js": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", - "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz", + "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==", "dev": true }, "@humanwhocodes/config-array": { @@ -7159,9 +7159,9 @@ } }, "@koumoul/vjsf": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/@koumoul/vjsf/-/vjsf-2.22.0.tgz", - "integrity": "sha512-qQ23G+OX8Vf16K3ysxb7axk5A+tREjVwCxBShvvlQ9NETNqifpBqhHcjwMtkS0kxf7f8yA+hMWnJczm57d+EYA==", + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/@koumoul/vjsf/-/vjsf-2.22.1.tgz", + "integrity": "sha512-u5yaNTzeCTNyhJlhM6zLGbf7EPQQgBR64kNF1b3EMZFcXTpmClewU1n+lg8Wj6f5RDPLwAwlVSNPMdgpfMD9jg==", "requires": { "@mdi/font": "^6.5.95", "@mdi/js": "^6.5.95", @@ -7248,12 +7248,12 @@ } }, "@playwright/test": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.0.tgz", - "integrity": "sha512-xis/RXXsLxwThKnlIXouxmIvvT3zvQj1JE39GsNieMUrMpb3/GySHDh2j8itCG22qKVD4MYLBp7xB73cUW/UUw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", "dev": true, "requires": { - "playwright": "1.38.0" + "playwright": "1.38.1" } }, "@rollup/pluginutils": { @@ -8540,15 +8540,15 @@ } }, "eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", - "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz", + "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.49.0", + "@eslint/js": "8.50.0", "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -10300,19 +10300,19 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, "playwright": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.0.tgz", - "integrity": "sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.38.0" + "playwright-core": "1.38.1" } }, "playwright-core": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.0.tgz", - "integrity": "sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", "dev": true }, "postcss": { 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/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 2df49e9908b5..26debe2a1895 100644 --- a/src/dispatch/static/dispatch/src/incident/api.js +++ b/src/dispatch/static/dispatch/src/incident/api.js @@ -70,4 +70,8 @@ export default { deleteEvent(incidentId, event_uuid) { return API.delete(`/${resource}/${incidentId}/event/${event_uuid}`) }, + + 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 ef4980f4e734..34287eea32b9 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" import moment from "moment-timezone" @@ -428,6 +429,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") }, @@ -452,6 +487,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 } }) + }, +}