diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f45cf86f1c08..4bbb95877b30 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -8,7 +8,7 @@ jobs: # Minimum code coverage per file COVERAGE_SINGLE: 50 # Minimum total code coverage - COVERAGE_TOTAL: 56 + COVERAGE_TOTAL: 55 runs-on: ubuntu-latest services: postgres: diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index b0a2b0c9e457..14c74e619e5d 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -336,7 +336,7 @@ def case_update_flow( # we get the case case = get(db_session=db_session, case_id=case_id) - if reporter_email and case and reporter_email != case.reporter.email: + if reporter_email and case and reporter_email != case.reporter.individual.email: # we run the case assign role flow for the reporter if it changed case_assign_role_flow( case_id=case.id, @@ -345,7 +345,7 @@ def case_update_flow( db_session=db_session, ) - if assignee_email and case and assignee_email != case.assignee.email: + if assignee_email and case and assignee_email != case.assignee.individual.email: # we run the case assign role flow for the assignee if it changed case_assign_role_flow( case_id=case.id, @@ -374,7 +374,7 @@ def case_update_flow( if case.tactical_group: # we update the tactical group - if reporter_email and reporter_email != case.reporter.email: + if reporter_email and reporter_email != case.reporter.individual.email: group_flows.update_group( subject=case, group=case.tactical_group, @@ -382,7 +382,7 @@ def case_update_flow( group_member=reporter_email, db_session=db_session, ) - if assignee_email and assignee_email != case.assignee.email: + if assignee_email and assignee_email != case.assignee.individual.email: group_flows.update_group( subject=case, group=case.tactical_group, @@ -488,6 +488,9 @@ def case_closed_status_flow(case: Case, db_session=None): if not storage_plugin: return + # we update the ticket + ticket_flows.update_case_ticket(case=case, db_session=db_session) + # Open document access if configured if storage_plugin.configuration.open_on_close: for document in case.documents: diff --git a/src/dispatch/feedback/incident/messaging.py b/src/dispatch/feedback/incident/messaging.py index b32b9daa4d02..470189ea62cc 100644 --- a/src/dispatch/feedback/incident/messaging.py +++ b/src/dispatch/feedback/incident/messaging.py @@ -1,9 +1,11 @@ import logging from typing import List -from dispatch.database.core import SessionLocal +from sqlalchemy.orm import Session + from dispatch.messaging.strings import ( INCIDENT_FEEDBACK_DAILY_REPORT, + CASE_FEEDBACK_DAILY_REPORT, MessageType, ) from dispatch.plugin import service as plugin_service @@ -15,7 +17,7 @@ def send_incident_feedback_daily_report( - commander_email: str, feedback: List[Feedback], project_id: int, db_session: SessionLocal + commander_email: str, feedback: List[Feedback], project_id: int, db_session: Session ): """Sends an incident feedback daily report to all incident commanders who received feedback.""" plugin = plugin_service.get_active_instance( @@ -62,3 +64,53 @@ def send_incident_feedback_daily_report( log.error(f"Error in sending {notification_text} email to {commander_email}: {e}") log.debug(f"Incident feedback daily report sent to {commander_email}.") + + +def send_case_feedback_daily_report( + assignee_email: str, feedback: List[Feedback], project_id: int, db_session: Session +): + """Sends an case feedback daily report to all case assignees who received feedback.""" + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=project_id, plugin_type="email" + ) + + if not plugin: + log.warning("Case feedback daily report not sent. Email plugin is not enabled.") + return + + items = [] + for piece in feedback: + participant = piece.participant.individual.name if piece.participant else "Anonymous" + items.append( + { + "name": piece.case.name, + "title": piece.case.title, + "rating": piece.rating, + "feedback": piece.feedback, + "participant": participant, + "created_at": piece.created_at, + } + ) + + name = subject = notification_text = "Case Feedback Daily Report" + assignee_fullname = feedback[0].case.assignee.individual.name + assignee_weblink = feedback[0].case.assignee.individual.weblink + + # Can raise exception "tenacity.RetryError: RetryError". (Email may still go through). + try: + plugin.instance.send( + assignee_email, + notification_text, + CASE_FEEDBACK_DAILY_REPORT, + MessageType.case_feedback_daily_report, + name=name, + subject=subject, + cc=plugin.project.owner_email, + items=items, + contact_fullname=assignee_fullname, + contact_weblink=assignee_weblink, + ) + except Exception as e: + log.error(f"Error in sending {notification_text} email to {assignee_email}: {e}") + + log.debug(f"Case feedback daily report sent to {assignee_email}.") diff --git a/src/dispatch/feedback/incident/scheduled.py b/src/dispatch/feedback/incident/scheduled.py index 63c202de5edf..0b0696acd736 100644 --- a/src/dispatch/feedback/incident/scheduled.py +++ b/src/dispatch/feedback/incident/scheduled.py @@ -2,13 +2,17 @@ from schedule import every import logging -from dispatch.database.core import SessionLocal +from sqlalchemy.orm import Session + from dispatch.decorators import scheduled_project_task, timer from dispatch.project.models import Project from dispatch.scheduler import scheduler -from .messaging import send_incident_feedback_daily_report -from .service import get_all_last_x_hours_by_project_id +from .messaging import send_incident_feedback_daily_report, send_case_feedback_daily_report +from .service import ( + get_all_incident_last_x_hours_by_project_id, + get_all_case_last_x_hours_by_project_id, +) log = logging.getLogger(__name__) @@ -17,21 +21,42 @@ def group_feedback_by_commander(feedback): """Groups feedback by commander.""" grouped = defaultdict(lambda: []) for piece in feedback: - grouped[piece.incident.commander.individual.email].append(piece) + if piece.incident and piece.incident.commander: + grouped[piece.incident.commander.individual.email].append(piece) + return grouped + + +def group_feedback_by_assignee(feedback): + """Groups feedback by assignee.""" + grouped = defaultdict(lambda: []) + for piece in feedback: + if piece.case and piece.case.assignee: + grouped[piece.case.assignee.individual.email].append(piece) return grouped @scheduler.add(every(1).day.at("18:00"), name="feedback-report-daily") @timer @scheduled_project_task -def feedback_report_daily(db_session: SessionLocal, project: Project): +def feedback_report_daily(db_session: Session, project: Project): """ - Fetches all incident feedback provided in the last 24 hours - and sends a daily report to the commanders who handled the incidents. + Fetches all incident and case feedback provided in the last 24 hours + and sends a daily report to the commanders and assignees who handled the incidents/cases. """ - feedback = get_all_last_x_hours_by_project_id(db_session=db_session, project_id=project.id) + incident_feedback = get_all_incident_last_x_hours_by_project_id( + db_session=db_session, project_id=project.id + ) - if feedback: - grouped_feedback = group_feedback_by_commander(feedback) - for commander_email, feedback in grouped_feedback.items(): + if incident_feedback: + grouped_incident_feedback = group_feedback_by_commander(incident_feedback) + for commander_email, feedback in grouped_incident_feedback.items(): send_incident_feedback_daily_report(commander_email, feedback, project.id, db_session) + + case_feedback = get_all_case_last_x_hours_by_project_id( + db_session=db_session, project_id=project.id + ) + + if case_feedback: + grouped_case_feedback = group_feedback_by_assignee(case_feedback) + for assignee_email, feedback in grouped_case_feedback.items(): + send_case_feedback_daily_report(assignee_email, feedback, project.id, db_session) diff --git a/src/dispatch/feedback/incident/service.py b/src/dispatch/feedback/incident/service.py index fd80be90909a..e9aa646b5035 100644 --- a/src/dispatch/feedback/incident/service.py +++ b/src/dispatch/feedback/incident/service.py @@ -4,6 +4,7 @@ from dispatch.incident import service as incident_service from dispatch.case import service as case_service from dispatch.incident.models import Incident +from dispatch.case.models import Case from dispatch.project.models import Project from .models import Feedback, FeedbackCreate, FeedbackUpdate @@ -19,7 +20,7 @@ def get_all(*, db_session): return db_session.query(Feedback) -def get_all_last_x_hours_by_project_id( +def get_all_incident_last_x_hours_by_project_id( *, db_session, hours: int = 24, project_id: int ) -> List[Optional[Feedback]]: """Returns all feedback provided in the last x hours by project id. Defaults to 24 hours.""" @@ -33,6 +34,20 @@ def get_all_last_x_hours_by_project_id( ) +def get_all_case_last_x_hours_by_project_id( + *, db_session, hours: int = 24, project_id: int +) -> List[Optional[Feedback]]: + """Returns all feedback provided in the last x hours by project id. Defaults to 24 hours.""" + return ( + db_session.query(Feedback) + .join(Case) + .join(Project) + .filter(Project.id == project_id) + .filter(Feedback.created_at >= datetime.utcnow() - timedelta(hours=hours)) + .all() + ) + + def create(*, db_session, feedback_in: FeedbackCreate) -> Feedback: """Creates a new piece of feedback.""" if feedback_in.incident: diff --git a/src/dispatch/messaging/email/utils.py b/src/dispatch/messaging/email/utils.py index 0b71dc6f9912..19aed7dfebaa 100644 --- a/src/dispatch/messaging/email/utils.py +++ b/src/dispatch/messaging/email/utils.py @@ -12,6 +12,7 @@ INCIDENT_DAILY_REPORT_DESCRIPTION, INCIDENT_FEEDBACK_DAILY_REPORT_DESCRIPTION, INCIDENT_TASK_REMINDER_DESCRIPTION, + CASE_FEEDBACK_DAILY_REPORT_DESCRIPTION, MessageType, render_message_template, ) @@ -42,6 +43,10 @@ def get_template(message_type: MessageType, project_id: int): "notification_list.mjml", INCIDENT_FEEDBACK_DAILY_REPORT_DESCRIPTION, ), + MessageType.case_feedback_daily_report: ( + "notification_list.mjml", + CASE_FEEDBACK_DAILY_REPORT_DESCRIPTION, + ), MessageType.incident_daily_report: ( "notification_list.mjml", INCIDENT_DAILY_REPORT_DESCRIPTION, diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 70e0353d979d..b98cfc7e26a9 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -43,6 +43,7 @@ class MessageType(DispatchEnum): service_feedback = "service-feedback" task_add_to_incident = "task-add-to-incident" case_rating_feedback = "case-rating-feedback" + case_feedback_daily_report = "case-feedback-daily-report" INCIDENT_STATUS_DESCRIPTIONS = { @@ -80,6 +81,11 @@ class MessageType(DispatchEnum): "\n", " " ).strip() +CASE_FEEDBACK_DAILY_REPORT_DESCRIPTION = """ +This is a daily report of feedback about cases handled by you.""".replace( + "\n", " " +).strip() + INCIDENT_WEEKLY_REPORT_TITLE = """ Incidents Weekly Report""".replace( "\n", " " @@ -999,6 +1005,15 @@ class MessageType(DispatchEnum): {"title": "Created At", "text": "", "datetime": "{{ created_at}}"}, ] +CASE_FEEDBACK_DAILY_REPORT = [ + {"title": "Case", "text": "{{ name }}"}, + {"title": "Case Title", "text": "{{ title }}"}, + {"title": "Rating", "text": "{{ rating }}"}, + {"title": "Feedback", "text": "{{ feedback }}"}, + {"title": "Participant", "text": "{{ participant }}"}, + {"title": "Created At", "text": "", "datetime": "{{ created_at}}"}, +] + INCIDENT_WEEKLY_REPORT_HEADER = { "type": "header", "text": INCIDENT_WEEKLY_REPORT_TITLE, diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 08500ff06d88..51019e53bb98 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -106,6 +106,7 @@ SignalFilterCreate, SignalInstance, ) +from dispatch.ticket import flows as ticket_flows log = logging.getLogger(__name__) @@ -1008,6 +1009,10 @@ def handle_case_participant_role_activity( organization_slug=context["subject"].organization_slug, ) case.status = CaseStatus.triage + + # we update the ticket + ticket_flows.update_case_ticket(case=case, db_session=db_session) + case_flows.update_conversation(case, db_session) db_session.commit() @@ -1073,6 +1078,10 @@ def reopen_button_click( ack() case = case_service.get(db_session=db_session, case_id=context["subject"].id) case.status = CaseStatus.triage + + # we update the ticket + ticket_flows.update_case_ticket(case=case, db_session=db_session) + db_session.commit() # update case message @@ -1644,6 +1653,10 @@ def triage_button_click( ) case.status = CaseStatus.triage db_session.commit() + + # we update the ticket + ticket_flows.update_case_ticket(case=case, db_session=db_session) + case_flows.update_conversation(case, db_session) diff --git a/src/dispatch/static/dispatch/src/case/DetailsTab.vue b/src/dispatch/static/dispatch/src/case/DetailsTab.vue index b216d2d19186..6873d90c9038 100644 --- a/src/dispatch/static/dispatch/src/case/DetailsTab.vue +++ b/src/dispatch/static/dispatch/src/case/DetailsTab.vue @@ -62,7 +62,7 @@ /> - + diff --git a/src/dispatch/static/dispatch/src/case/priority/CasePrioritySelect.vue b/src/dispatch/static/dispatch/src/case/priority/CasePrioritySelect.vue index ecbc78c6b2dd..47fb41115fe8 100644 --- a/src/dispatch/static/dispatch/src/case/priority/CasePrioritySelect.vue +++ b/src/dispatch/static/dispatch/src/case/priority/CasePrioritySelect.vue @@ -140,22 +140,8 @@ export default { }, watch: { - project: { - handler(newProject) { - if (newProject?.id !== this.lastProjectId) { - // Check if we're moving to a valid project (not null) - if (this.lastProjectId) { - this.lastProjectId = newProject.id - this.resetSelection() - this.fetchData() - } else { - // If new project is null/undefined, just update lastProjectId - this.lastProjectId = null - } - } - this.validatePriority() - }, - deep: true, + project() { + this.fetchData() }, }, diff --git a/src/dispatch/static/dispatch/src/case/type/CaseTypeSelect.vue b/src/dispatch/static/dispatch/src/case/type/CaseTypeSelect.vue index ce9693799055..dcf695fa1f6d 100644 --- a/src/dispatch/static/dispatch/src/case/type/CaseTypeSelect.vue +++ b/src/dispatch/static/dispatch/src/case/type/CaseTypeSelect.vue @@ -130,7 +130,7 @@ export default { } } - filterOptions = SearchUtils.createParametersFromTableOptions({ ...filterOptions }) + filterOptions = SearchUtils.createParametersFromTableOptions({ ...filterOptions }, "CaseType") CaseTypeApi.getAll(filterOptions) .then((response) => { @@ -169,22 +169,9 @@ export default { }, watch: { - project: { - handler(newProject) { - if (newProject?.id !== this.lastProjectId) { - // Check if we're moving to a valid project (not null) - if (this.lastProjectId) { - this.lastProjectId = newProject.id - this.resetSelection() - this.fetchData() - } else { - // If new project is null/undefined, just update lastProjectId - this.lastProjectId = null - } - } - this.validateType() - }, - deep: true, + project() { + this.validateType() + this.fetchData() }, }, diff --git a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue index 945b18d96c0e..ac05614ad7e7 100644 --- a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue +++ b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue @@ -56,7 +56,7 @@ /> - + diff --git a/src/dispatch/static/dispatch/src/incident/priority/IncidentPrioritySelect.vue b/src/dispatch/static/dispatch/src/incident/priority/IncidentPrioritySelect.vue index 9feb7e5e4353..a14387ae925a 100644 --- a/src/dispatch/static/dispatch/src/incident/priority/IncidentPrioritySelect.vue +++ b/src/dispatch/static/dispatch/src/incident/priority/IncidentPrioritySelect.vue @@ -130,23 +130,9 @@ export default { }, watch: { - project: { - handler(newProject) { - if (newProject?.id !== this.lastProjectId) { - // Check if we're moving to a valid project (not null) - if (this.lastProjectId) { - this.lastProjectId = newProject.id - this.resetSelection() - this.fetchData() - } else { - // If new project is null/undefined, just update lastProjectId - this.lastProjectId = null - } - } - - this.validatePriority() - }, - deep: true, + project() { + this.validatePriority() + this.fetchData() }, status() { this.validatePriority() diff --git a/src/dispatch/static/dispatch/src/incident/type/IncidentTypeSelect.vue b/src/dispatch/static/dispatch/src/incident/type/IncidentTypeSelect.vue index 5040af7002bb..0182b9d0d27c 100644 --- a/src/dispatch/static/dispatch/src/incident/type/IncidentTypeSelect.vue +++ b/src/dispatch/static/dispatch/src/incident/type/IncidentTypeSelect.vue @@ -7,7 +7,7 @@ :label="label" return-object :loading="loading" - :rules="[validationRule]" + :rules="[is_type_in_project]" >