From c602b6aa901bae86b355985d8b5d795789526c9f Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 08:19:03 -0700 Subject: [PATCH 1/4] [#995][IMP] Add detailed case update activity logs - Add field-level case activity entries for updates to state, customer, case name, SOC ticket, owner, severity, classification, status, reviewer, protagonists, and tags. - Snapshot previous case values before update processing to log only true changes. - Add a local logging helper to standardize case activity tracking with case-scoped context. - Convert status logging to human-readable labels instead of internal short codes. - Keep a fallback generic "case updated" entry only when no specific tracked field changed. --- source/app/business/cases.py | 166 ++++++++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 22 deletions(-) diff --git a/source/app/business/cases.py b/source/app/business/cases.py index 5ffc80433..5dc9bfe7d 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -55,6 +55,9 @@ from app.datamgmt.reporter.report_db import export_case_notes_json from app.datamgmt.manage.manage_cases_db import get_filtered_cases from app.datamgmt.case.case_db import get_first_case_with_customer +from app.models.authorization import User +from app.models.cases import CaseProtagonist +from app.models.cases import CaseStatus from app.models.cases import Cases from app.models.cases import ReviewStatusList from app.models.customers import Client @@ -163,24 +166,91 @@ def cases_delete(case_identifier): def cases_update(case: Cases, updated_case, protagonists, tags) -> Cases: try: + with db.session.no_autoflush: + previous_case = Cases.query.with_entities( + Cases.name, + Cases.soc_id, + Cases.client_id, + Cases.owner_id, + Cases.severity_id, + Cases.classification_id, + Cases.status_id, + Cases.state_id, + Cases.reviewer_id, + ).filter( + Cases.case_id == case.case_id + ).first() + + previous_protagonists = CaseProtagonist.query.with_entities( + CaseProtagonist.name, + CaseProtagonist.role + ).filter( + CaseProtagonist.case_id == case.case_id + ).all() + + if previous_case is None: + raise BusinessProcessingError('Tried to update a non-existing case') + + previous_case_name = previous_case.name + previous_case_soc_id = previous_case.soc_id + previous_case_customer_id = previous_case.client_id + previous_case_owner_id = previous_case.owner_id + previous_case_severity_id = previous_case.severity_id + previous_case_classification_id = previous_case.classification_id + previous_case_status_id = previous_case.status_id + previous_case_state = previous_case.state_id + previous_case_reviewer_id = previous_case.reviewer_id + + previous_protagonist_values = sorted([(p.name, p.role) for p in previous_protagonists if p.name]) + previous_tag_values = sorted([tag.tag_title for tag in case.tags]) + closed_state_id = get_case_state_by_name('Closed').state_id - previous_case_state = case.state_id - case_previous_reviewer_id = case.reviewer_id db.session.commit() - - if previous_case_state != updated_case.state_id: - if updated_case.state_id == closed_state_id: - track_activity('case closed', caseid=case.case_id) + activity_logged = False + + def log_case_activity(message): + nonlocal activity_logged + track_activity(message, caseid=case.case_id) + activity_logged = True + + def get_user_name(user_id, default='Unassigned'): + if user_id is None: + return default + user = User.query.filter(User.id == user_id).first() + if not user: + return default + return user.name if user.name else user.user + + def get_case_status_name(status_id): + status_display_names = { + CaseStatus.unknown: 'Unknown', + CaseStatus.false_positive: 'False Positive', + CaseStatus.true_positive_with_impact: 'True Positive with impact', + CaseStatus.true_positive_without_impact: 'True Positive without impact', + CaseStatus.legitimate: 'Legitimate', + CaseStatus.not_applicable: 'Not applicable', + } + try: + return status_display_names[CaseStatus(status_id)] + except ValueError: + return str(status_id) + + if previous_case_state != case.state_id: + state_name = case.state.state_name if case.state else str(case.state_id) + log_case_activity(f'case state changed to "{state_name}"') + + if case.state_id == closed_state_id: + log_case_activity('case closed') res = close_case(case.case_id) if not res: raise BusinessProcessingError('Tried to close an non-existing case') # Close the related alerts - if updated_case.alerts: + if case.alerts: close_status = get_alert_status_by_name('Closed') - case_status_id_mapped = map_alert_resolution_to_case_status(updated_case.status_id) + case_status_id_mapped = map_alert_resolution_to_case_status(case.status_id) - for alert in updated_case.alerts: + for alert in case.alerts: if alert.alert_status_id != close_status.status_id: alert.alert_status_id = close_status.status_id alert = call_modules_hook('on_postload_alert_update', alert, caseid=case.case_id) @@ -190,34 +260,86 @@ def cases_update(case: Cases, updated_case, protagonists, tags) -> Cases: alert = call_modules_hook('on_postload_alert_resolution_update', alert, caseid=case.case_id) - track_activity( + log_case_activity( f'closing alert ID {alert.alert_id} due to case #{case.case_id} being closed', - caseid=case.case_id, ctx_less=False) + ) db.session.add(alert) - elif previous_case_state == closed_state_id and updated_case.state_id != closed_state_id: - track_activity('case re-opened', caseid=case.case_id) + elif previous_case_state == closed_state_id and case.state_id != closed_state_id: + log_case_activity('case re-opened') res = reopen_case(case.case_id) if not res: raise BusinessProcessingError('Tried to re-open an non-existing case') - if case_previous_reviewer_id != updated_case.reviewer_id: - if updated_case.reviewer_id is None: - track_activity('case reviewer removed', caseid=case.case_id) - updated_case.review_status_id = get_review_id_from_name(ReviewStatusList.not_reviewed) + if previous_case_customer_id != case.client_id: + customer_name = case.client.name if case.client else str(case.client_id) + log_case_activity(f'case customer updated to "{customer_name}"') + + if previous_case_name != case.name: + if case.name: + log_case_activity(f'case name updated to "{case.name}"') + else: + log_case_activity('case name cleared') + + if previous_case_soc_id != case.soc_id: + if case.soc_id: + log_case_activity(f'case SOC ticket updated to "{case.soc_id}"') + else: + log_case_activity('case SOC ticket cleared') + + if previous_case_owner_id != case.owner_id: + owner_name = get_user_name(case.owner_id) + log_case_activity(f'case owner updated to "{owner_name}"') + + if previous_case_severity_id != case.severity_id: + severity_name = case.severity.severity_name if case.severity else str(case.severity_id) + log_case_activity(f'case severity updated to "{severity_name}"') + + if previous_case_classification_id != case.classification_id: + classification_name = case.classification.name if case.classification else str(case.classification_id) + log_case_activity(f'case classification updated to "{classification_name}"') + + if previous_case_status_id != case.status_id: + status_name = get_case_status_name(case.status_id) + log_case_activity(f'case status updated to "{status_name}"') + + if previous_case_reviewer_id != case.reviewer_id: + if case.reviewer_id is None: + log_case_activity('case reviewer removed') + case.review_status_id = get_review_id_from_name(ReviewStatusList.not_reviewed) else: - track_activity('case reviewer changed', caseid=case.case_id) + reviewer_name = get_user_name(case.reviewer_id) + log_case_activity(f'case reviewer updated to "{reviewer_name}"') + + register_case_protagonists(case.case_id, protagonists) + if protagonists is not None: + updated_protagonists = CaseProtagonist.query.with_entities( + CaseProtagonist.name, + CaseProtagonist.role + ).filter( + CaseProtagonist.case_id == case.case_id + ).all() + updated_protagonist_values = sorted([(p.name, p.role) for p in updated_protagonists if p.name]) + if updated_protagonist_values != previous_protagonist_values: + log_case_activity('case protagonists updated') - register_case_protagonists(updated_case.case_id, protagonists) save_case_tags(tags, case) + if tags is not None: + updated_tag_values = sorted([tag.tag_title for tag in case.tags]) + if updated_tag_values != previous_tag_values: + if updated_tag_values: + log_case_activity(f'case tags updated to: {", ".join(updated_tag_values)}') + else: + log_case_activity('case tags cleared') - updated_case = call_modules_hook('on_postload_case_update', data=updated_case, caseid=case.case_id) + case = call_modules_hook('on_postload_case_update', data=case, caseid=case.case_id) add_obj_history_entry(case, 'case info updated') - track_activity(f'case updated "{updated_case.name}"', caseid=case.case_id) + if not activity_logged: + track_activity(f'case updated "{case.name}"', caseid=case.case_id) - return updated_case + return case except BusinessProcessingError as e: raise e From ad223241c75b25d373d0a1410da7e4f1a2787376 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 08:19:07 -0700 Subject: [PATCH 2/4] [#995][IMP] Log case status updates with display labels - Add explicit status display-name mapping in the case status update endpoint. - Update object history entries to store the readable status label. - Emit case activity tracking for status changes using the readable label and case id. --- source/app/blueprints/rest/case/case_routes.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/source/app/blueprints/rest/case/case_routes.py b/source/app/blueprints/rest/case/case_routes.py index d3a141205..0497349bb 100644 --- a/source/app/blueprints/rest/case/case_routes.py +++ b/source/app/blueprints/rest/case/case_routes.py @@ -271,10 +271,21 @@ def case_update_status(caseid): if status not in case_status: return response_error('Invalid status') + status_display_names = { + CaseStatus.unknown: 'Unknown', + CaseStatus.false_positive: 'False Positive', + CaseStatus.true_positive_with_impact: 'True Positive with impact', + CaseStatus.true_positive_without_impact: 'True Positive without impact', + CaseStatus.legitimate: 'Legitimate', + CaseStatus.not_applicable: 'Not applicable', + } + status_display = status_display_names[CaseStatus(status)] + case.status_id = status - add_obj_history_entry(case, f'status updated to {CaseStatus(status).name}') + add_obj_history_entry(case, f'status updated to {status_display}') db.session.commit() + track_activity(f'case status updated to {status_display}', caseid) return response_success('Case status updated', data=case.status_id) From 455ee181013b13521f280ff07d5aae6ddae34ebd Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 08:19:10 -0700 Subject: [PATCH 3/4] [#995][IMP] Log event flag and unflag actions - Add case activity tracking when a timeline event is flagged or unflagged. - Include the event title and resulting flag state in the activity message. - Scope the activity entry to the parent case id for reporting consistency. --- source/app/blueprints/rest/case/case_timeline_routes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 2c8a83cbc..e65526510 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -646,6 +646,10 @@ def event_flag(cur_id, caseid): event.event_is_flagged = not event.event_is_flagged db.session.commit() + track_activity( + f'event "{event.event_title}" {"flagged" if event.event_is_flagged else "unflagged"}', + caseid=caseid + ) collab_notify(caseid, 'events', 'flagged' if event.event_is_flagged else "un-flagged", cur_id) From d64614e8c613a7bab8458952fffad82e4c512b70 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 08:19:14 -0700 Subject: [PATCH 4/4] [#995][FIX] Scope task deletion activity to case - Attach case id context to task deletion activity logs. - Ensure deleted-task entries are queryable in case-scoped activity reporting for FR #995. --- source/app/business/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/app/business/tasks.py b/source/app/business/tasks.py index 15345c69a..a8bb1f0e7 100644 --- a/source/app/business/tasks.py +++ b/source/app/business/tasks.py @@ -43,7 +43,7 @@ def tasks_delete(task: CaseTasks): delete_task(task.id) update_tasks_state(caseid=task.task_case_id) call_modules_hook('on_postload_task_delete', task.id, caseid=task.task_case_id) - track_activity(f'deleted task "{task.task_title}"') + track_activity(f'deleted task "{task.task_title}"', caseid=task.task_case_id) def tasks_create(task: CaseTasks, task_assignee_list) -> CaseTasks: