@cryptotaxi247 / CoPilot / commits / 50161fd2

602 alert incidents filtering bug (#626)

* feat: add endpoint to delete alerts by title filter with customer access control * feat: add bulk delete functionality for alerts by title filter * precommit fixes * fix: update current version to 0.1.28

taylor_socfortress committed Jan 24, 2026 at 12:56 UTC 50161fd2054245cb0a489fa3229276fcb7b8d19b
4 files changed +342 -174
backend/app/incidents/routes/db_operations.py
+68
@@ -910,6 +910,74 @@ async def delete_alerts_endpoint(request: DeleteAlertsRequest, db: AsyncSession
910 )
911
912
913 +@incidents_db_operations_router.delete("/alerts/by-title/{title_filter}", response_model=DeleteAlertsResponse)
914 +async def delete_alerts_by_title_endpoint(
915 + title_filter: str,
916 + current_user: User = Depends(AuthHandler().get_current_user),
917 + db: AsyncSession = Depends(get_db),
918 +):
919 + """
920 + Delete alerts matching a title filter.
921 +
922 + The title_filter will match any alert where the title contains the filter string.
923 + For example, filtering by "File" will match "File added to the system."
924 +
925 + Args:
926 + title_filter: The string to filter alert titles by (case-insensitive partial match)
927 + current_user: Current authenticated user
928 + db: Database session
929 +
930 + Returns:
931 + DeleteAlertsResponse with lists of deleted and not deleted alert IDs
932 + """
933 + logger.info(f"Deleting alerts with title filter '{title_filter}' for user: {current_user.username}")
934 +
935 + # Get customer access filtering
936 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
937 +
938 + # Build query to find matching alerts
939 + query = select(Alert).where(Alert.alert_name.ilike(f"%{title_filter}%"))
940 +
941 + # Apply customer filtering if not admin/analyst
942 + if "*" not in accessible_customers:
943 + query = query.where(Alert.customer_code.in_(accessible_customers))
944 +
945 + result = await db.execute(query)
946 + matching_alerts = result.scalars().all()
947 +
948 + if not matching_alerts:
949 + return DeleteAlertsResponse(
950 + message=f"No alerts found matching title filter: {title_filter}",
951 + deleted_alert_ids=[],
952 + not_deleted_alert_ids=[],
953 + success=True,
954 + )
955 +
956 + deleted_alert_ids = []
957 + not_deleted_alert_ids = []
958 +
959 + for alert in matching_alerts:
960 + try:
961 + # Check if alert is linked to a case
962 + await is_alert_linked_to_case(alert.id, db)
963 + # Delete the alert
964 + await delete_alert(alert.id, db)
965 + deleted_alert_ids.append(alert.id)
966 + except HTTPException as e:
967 + if e.status_code == 400:
968 + logger.info(f"Alert {alert.id} is linked to a case and cannot be deleted. Skipping.")
969 + not_deleted_alert_ids.append(alert.id)
970 + else:
971 + raise e
972 +
973 + return DeleteAlertsResponse(
974 + message=f"Processed {len(matching_alerts)} alerts matching '{title_filter}'. Deleted: {len(deleted_alert_ids)}, Skipped: {len(not_deleted_alert_ids)}",
975 + deleted_alert_ids=deleted_alert_ids,
976 + not_deleted_alert_ids=not_deleted_alert_ids,
977 + success=True,
978 + )
979 +
980 +
981 @incidents_db_operations_router.get("/alerts/status/{status}", response_model=AlertOutResponse)
982 async def list_alerts_by_status_endpoint(
983 status: AlertStatus,
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.27"
10 +CURRENT_VERSION = "0.1.28"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/incidentManagement/alerts.ts
+9
@@ -177,6 +177,15 @@ export default {
177 }
178 )
179 },
180 + deleteAlertsByTitle(titleFilter: string) {
181 + return HttpClient.delete<
182 + FlaskBaseResponse & {
183 + deleted_alert_ids: number[]
184 + not_deleted_alert_ids: number[]
185 + message: string
186 + }
187 + >(`/incidents/db_operations/alerts/by-title/${encodeURIComponent(titleFilter)}`)
188 + },
189 getAlertContext(alertContextId: number) {
190 return HttpClient.get<FlaskBaseResponse & { alert_context: AlertContext }>(
191 `/incidents/db_operations/alert/context/${alertContextId}`
frontend/src/components/incidentManagement/alerts/AlertsList.vue
+264 -173
@@ -104,6 +104,13 @@
104 </template>
105 </n-button>
106 </n-badge>
107 +
108 + <n-button size="small" secondary @click="showDeleteByTitleModal = true">
109 + <template #icon>
110 + <Icon :name="TrashIcon" />
111 + </template>
112 + <span class="hidden sm:inline">Bulk Delete</span>
113 + </n-button>
114 </div>
115
116 <CollapseKeepAlive v-if="showFilters" :show="showFiltersView" embedded arrow="top-right">
@@ -184,6 +191,37 @@
191 </n-card>
192 </n-collapse-transition>
193
194 + <n-modal
195 + v-model:show="showDeleteByTitleModal"
196 + preset="dialog"
197 + title="Delete Alerts by Title"
198 + :positive-text="deletingByTitle ? 'Deleting...' : 'Delete'"
199 + negative-text="Cancel"
200 + :positive-button-props="{ disabled: !titleFilterInput || deletingByTitle }"
201 + @positive-click="deleteAlertsByTitle"
202 + >
203 + <div class="flex flex-col gap-4">
204 + <p class="text-sm opacity-70">
205 + Delete all alerts matching a title filter. This will perform a partial, case-insensitive match.
206 + </p>
207 + <n-input
208 + v-model:value="titleFilterInput"
209 + placeholder="e.g., 'File' to match 'File added to the system.'"
210 + :disabled="deletingByTitle"
211 + @keyup.enter="titleFilterInput && deleteAlertsByTitle()"
212 + >
213 + <template #prefix>
214 + <Icon :name="FilterIcon" />
215 + </template>
216 + </n-input>
217 + <n-alert v-if="titleFilterInput" type="warning" :bordered="false">
218 + This will delete all alerts with titles containing "{{ titleFilterInput }}".
219 + <br />
220 + <strong>This action cannot be undone.</strong>
221 + </n-alert>
222 + </div>
223 + </n-modal>
224 +
225 <n-spin :show="loading">
226 <div class="my-3 flex min-h-52 flex-col gap-2">
227 <template v-if="alertsList.length">
@@ -228,18 +266,21 @@ import { useResizeObserver, useStorage } from "@vueuse/core"
266 import axios from "axios"
267 import _orderBy from "lodash/orderBy"
268 import {
231 - NBadge,
232 - NButton,
233 - NCard,
234 - NCollapseTransition,
235 - NEmpty,
236 - NPagination,
237 - NPopconfirm,
238 - NPopover,
239 - NScrollbar,
240 - NSelect,
241 - NSpin,
242 - useMessage
269 + NAlert,
270 + NBadge,
271 + NButton,
272 + NCard,
273 + NCollapseTransition,
274 + NEmpty,
275 + NInput,
276 + NModal,
277 + NPagination,
278 + NPopconfirm,
279 + NPopover,
280 + NScrollbar,
281 + NSelect,
282 + NSpin,
283 + useMessage
284 } from "naive-ui"
285 import { computed, defineAsyncComponent, nextTick, onBeforeMount, provide, ref, watch } from "vue"
286 import Api from "@/api"
@@ -249,13 +290,13 @@ import AlertItem from "./AlertItem.vue"
290 import AlertsFilters from "./AlertsFilters.vue"
291
292 const {
252 - highlight,
253 - preset,
254 - showFilters = true
293 + highlight,
294 + preset,
295 + showFilters = true
296 } = defineProps<{
256 - highlight?: string | null
257 - preset?: AlertsListFilter[]
258 - showFilters?: boolean
297 + highlight?: string | null
298 + preset?: AlertsListFilter[]
299 + showFilters?: boolean
300 }>()
301
302 const AlertMergeCaseButton = defineAsyncComponent(() => import("./AlertMergeCaseButton.vue"))
@@ -269,6 +310,9 @@ const checkedNoLinkedAlerts = computed(() => checkedAlerts.value.filter(alert =>
310 const message = useMessage()
311 const loading = ref(false)
312 const deleting = ref(false)
313 +const deletingByTitle = ref(false)
314 +const showDeleteByTitleModal = ref(false)
315 +const titleFilterInput = ref("")
316 const showFiltersView = useStorage<boolean>("incident-management-alerts-list-filters-view-state", false, localStorage)
317 const alertsList = ref<Alert[]>([])
318 const availableUsers = ref<string[]>([])
@@ -284,8 +328,8 @@ const header = ref()
328 const pageSlot = ref(8)
329 const sort = defineModel<"asc" | "desc">("sort", { default: "desc" })
330 const sortOptions = [
287 - { label: "Desc", value: "desc" },
288 - { label: "Asc", value: "asc" }
331 + { label: "Desc", value: "desc" },
332 + { label: "Asc", value: "asc" }
333 ]
334
335 const totalFiltered = ref(0)
@@ -298,43 +342,43 @@ const filtersCTX = ref<{ setFilter: (payload: AlertsListFilter[]) => void } | nu
342 const filters = ref<AlertsListFilter[]>([])
343
344 const filtered = computed<boolean>(() => {
301 - return !!filters.value.length
345 + return !!filters.value.length
346 })
347
348 const highlightedItemFound = ref(!highlight)
349 const highlightedItemOpened = ref(!highlight)
350
351 watch([currentPage, sort], () => {
308 - getData()
352 + getData()
353 })
354
355 watch(pageSize, () => {
312 - if (currentPage.value === 1) {
313 - getData()
314 - } else {
315 - currentPage.value = 1
316 - }
356 + if (currentPage.value === 1) {
357 + getData()
358 + } else {
359 + currentPage.value = 1
360 + }
361 })
362
363 watch(
320 - alertsList,
321 - () => {
322 - if (
323 - alertsList.value.length &&
324 - !alertsList.value.find(o => o.id.toString() === highlight) &&
325 - currentPage.value < totalFiltered.value &&
326 - !highlightedItemFound.value
327 - ) {
328 - nextTick(() => {
329 - currentPage.value++
330 - })
331 - }
332 -
333 - if (alertsList.value.find(o => o.id.toString() === highlight)) {
334 - highlightedItemFound.value = true
335 - }
336 - },
337 - { immediate: true }
364 + alertsList,
365 + () => {
366 + if (
367 + alertsList.value.length &&
368 + !alertsList.value.find(o => o.id.toString() === highlight) &&
369 + currentPage.value < totalFiltered.value &&
370 + !highlightedItemFound.value
371 + ) {
372 + nextTick(() => {
373 + currentPage.value++
374 + })
375 + }
376 +
377 + if (alertsList.value.find(o => o.id.toString() === highlight)) {
378 + highlightedItemFound.value = true
379 + }
380 + },
381 + { immediate: true }
382 )
383
384 provide("assignable-users", availableUsers)
@@ -342,176 +386,223 @@ provide("assignable-users", availableUsers)
386 provide("linkable-cases", linkableCases)
387
388 function applyFilters(newFilters: AlertsListFilter[]) {
345 - filters.value = newFilters
346 - getData()
389 + filters.value = newFilters
390 + getData()
391 }
392
393 function updateAlert(updatedAlert: Alert) {
350 - const alertIndex = alertsList.value.findIndex(o => o.id === updatedAlert.id)
351 - if (alertIndex !== -1) {
352 - alertsList.value[alertIndex] = updatedAlert
353 - }
394 + const alertIndex = alertsList.value.findIndex(o => o.id === updatedAlert.id)
395 + if (alertIndex !== -1) {
396 + alertsList.value[alertIndex] = updatedAlert
397 + }
398 }
399
400 function isChecked(alert: Alert) {
357 - return !!checkedAlerts.value.find(o => o.id === alert.id)
401 + return !!checkedAlerts.value.find(o => o.id === alert.id)
402 }
403
404 function toggleCheck(alert: Alert) {
361 - const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
405 + const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
406
363 - if (checkedAlerts.value.find(o => o.id === alert.id)) {
364 - checkedAlerts.value.splice(alertIndex, 1)
365 - } else {
366 - checkedAlerts.value.push(alert)
367 - }
407 + if (checkedAlerts.value.find(o => o.id === alert.id)) {
408 + checkedAlerts.value.splice(alertIndex, 1)
409 + } else {
410 + checkedAlerts.value.push(alert)
411 + }
412 }
413
414 function resetChecked() {
371 - checkedAlerts.value = []
415 + checkedAlerts.value = []
416 }
417
418 function setALlChecked() {
375 - for (const alert of alertsList.value) {
376 - if (!isChecked(alert)) {
377 - toggleCheck(alert)
378 - }
379 - }
419 + for (const alert of alertsList.value) {
420 + if (!isChecked(alert)) {
421 + toggleCheck(alert)
422 + }
423 + }
424 }
425
426 function removeChecked(alert: Alert) {
383 - const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
427 + const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
428
385 - if (checkedAlerts.value.find(o => o.id === alert.id)) {
386 - checkedAlerts.value.splice(alertIndex, 1)
387 - }
429 + if (checkedAlerts.value.find(o => o.id === alert.id)) {
430 + checkedAlerts.value.splice(alertIndex, 1)
431 + }
432 }
433
434 function deleted(alert: Alert) {
391 - removeChecked(alert)
392 - getData()
435 + removeChecked(alert)
436 + getData()
437 }
438
439 function getData() {
396 - abortController?.abort()
397 - abortController = new AbortController()
398 -
399 - loading.value = true
400 -
401 - const query: Partial<AlertsQuery> = {
402 - page: currentPage.value,
403 - pageSize: pageSize.value,
404 - sort: sort.value
405 - }
406 -
407 - if (filtered.value) {
408 - query.filters = filters.value
409 - }
410 -
411 - Api.incidentManagement.alerts
412 - .getAlertsList(query, abortController.signal)
413 - .then(res => {
414 - if (res.data.success) {
415 - alertsList.value = res.data?.alerts || []
416 - total.value = res.data.total || 0
417 - totalFiltered.value = res.data.total_filtered ?? total.value ?? 0
418 - statusCloseTotal.value = res.data.closed || 0
419 - statusInProgressTotal.value = res.data.in_progress || 0
420 - statusOpenTotal.value = res.data.open || 0
421 - } else {
422 - message.warning(res.data?.message || "An error occurred. Please try again later.")
423 - }
424 - loading.value = false
425 - })
426 - .catch(err => {
427 - if (!axios.isCancel(err)) {
428 - alertsList.value = []
429 -
430 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
431 - loading.value = false
432 - }
433 - })
440 + abortController?.abort()
441 + abortController = new AbortController()
442 +
443 + loading.value = true
444 +
445 + const query: Partial<AlertsQuery> = {
446 + page: currentPage.value,
447 + pageSize: pageSize.value,
448 + sort: sort.value
449 + }
450 +
451 + if (filtered.value) {
452 + query.filters = filters.value
453 + }
454 +
455 + Api.incidentManagement.alerts
456 + .getAlertsList(query, abortController.signal)
457 + .then(res => {
458 + if (res.data.success) {
459 + alertsList.value = res.data?.alerts || []
460 + total.value = res.data.total || 0
461 + totalFiltered.value = res.data.total_filtered ?? total.value ?? 0
462 + statusCloseTotal.value = res.data.closed || 0
463 + statusInProgressTotal.value = res.data.in_progress || 0
464 + statusOpenTotal.value = res.data.open || 0
465 + } else {
466 + message.warning(res.data?.message || "An error occurred. Please try again later.")
467 + }
468 + loading.value = false
469 + })
470 + .catch(err => {
471 + if (!axios.isCancel(err)) {
472 + alertsList.value = []
473 +
474 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
475 + loading.value = false
476 + }
477 + })
478 }
479
480 function getAvailableUsers() {
437 - Api.incidentManagement.alerts
438 - .getAvailableUsers()
439 - .then(res => {
440 - if (res.data.success) {
441 - availableUsers.value = res.data?.available_users || []
442 - } else {
443 - message.warning(res.data?.message || "An error occurred. Please try again later.")
444 - }
445 - })
446 - .catch(err => {
447 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
448 - })
481 + Api.incidentManagement.alerts
482 + .getAvailableUsers()
483 + .then(res => {
484 + if (res.data.success) {
485 + availableUsers.value = res.data?.available_users || []
486 + } else {
487 + message.warning(res.data?.message || "An error occurred. Please try again later.")
488 + }
489 + })
490 + .catch(err => {
491 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
492 + })
493 }
494
495 function getCases() {
452 - Api.incidentManagement.cases
453 - .getCasesList()
454 - .then(res => {
455 - if (res.data.success) {
456 - linkableCases.value = _orderBy(res.data?.cases || [], ["id"], ["desc"])
457 - } else {
458 - message.warning(res.data?.message || "An error occurred. Please try again later.")
459 - }
460 - })
461 - .catch(err => {
462 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
463 - })
496 + Api.incidentManagement.cases
497 + .getCasesList()
498 + .then(res => {
499 + if (res.data.success) {
500 + linkableCases.value = _orderBy(res.data?.cases || [], ["id"], ["desc"])
501 + } else {
502 + message.warning(res.data?.message || "An error occurred. Please try again later.")
503 + }
504 + })
505 + .catch(err => {
506 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
507 + })
508 }
509
510 function deleteAlerts() {
467 - deleting.value = true
468 -
469 - Api.incidentManagement.alerts
470 - .deleteAlerts(checkedAlerts.value.map(o => o.id))
471 - .then(res => {
472 - if (res.data.success) {
473 - if (res.data.deleted_alert_ids.length) {
474 - for (const id of res.data.deleted_alert_ids) {
475 - toggleCheck({ id } as Alert)
476 - }
477 -
478 - if (res.data.not_deleted_alert_ids.length) {
479 - message.warning("Some alerts could not be deleted.")
480 - } else {
481 - message.success(res.data?.message || "Alerts deleted successfully.")
482 - }
483 - } else {
484 - message.warning("The selected alerts could not be deleted")
485 - }
486 -
487 - getData()
488 - } else {
489 - message.warning(res.data?.message || "An error occurred. Please try again later.")
490 - }
491 - })
492 - .catch(err => {
493 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
494 - })
495 - .finally(() => {
496 - deleting.value = false
497 - })
511 + deleting.value = true
512 +
513 + Api.incidentManagement.alerts
514 + .deleteAlerts(checkedAlerts.value.map(o => o.id))
515 + .then(res => {
516 + if (res.data.success) {
517 + if (res.data.deleted_alert_ids.length) {
518 + for (const id of res.data.deleted_alert_ids) {
519 + toggleCheck({ id } as Alert)
520 + }
521 +
522 + if (res.data.not_deleted_alert_ids.length) {
523 + message.warning("Some alerts could not be deleted.")
524 + } else {
525 + message.success(res.data?.message || "Alerts deleted successfully.")
526 + }
527 + } else {
528 + message.warning("The selected alerts could not be deleted")
529 + }
530 +
531 + getData()
532 + } else {
533 + message.warning(res.data?.message || "An error occurred. Please try again later.")
534 + }
535 + })
536 + .catch(err => {
537 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
538 + })
539 + .finally(() => {
540 + deleting.value = false
541 + })
542 +}
543 +
544 +function deleteAlertsByTitle() {
545 + if (!titleFilterInput.value) return
546 +
547 + deletingByTitle.value = true
548 +
549 + Api.incidentManagement.alerts
550 + .deleteAlertsByTitle(titleFilterInput.value)
551 + .then(res => {
552 + if (res.data.success) {
553 + const deletedCount = res.data.deleted_alert_ids.length
554 + const skippedCount = res.data.not_deleted_alert_ids.length
555 +
556 + if (deletedCount > 0) {
557 + if (skippedCount > 0) {
558 + message.warning(
559 + `Deleted ${deletedCount} alert(s). ${skippedCount} alert(s) were skipped (linked to cases).`
560 + )
561 + } else {
562 + message.success(`Successfully deleted ${deletedCount} alert(s).`)
563 + }
564 +
565 + // Clear any checked alerts that were deleted
566 + checkedAlerts.value = checkedAlerts.value.filter(
567 + alert => !res.data.deleted_alert_ids.includes(alert.id)
568 + )
569 +
570 + // Refresh the list
571 + getData()
572 + } else {
573 + message.info("No alerts were deleted. They may be linked to cases or not found.")
574 + }
575 +
576 + // Close modal and reset input
577 + showDeleteByTitleModal.value = false
578 + titleFilterInput.value = ""
579 + } else {
580 + message.warning(res.data?.message || "An error occurred. Please try again later.")
581 + }
582 + })
583 + .catch(err => {
584 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
585 + })
586 + .finally(() => {
587 + deletingByTitle.value = false
588 + })
589 }
590
591 useResizeObserver(header, entries => {
501 - const entry = entries[0]
502 - const { width } = entry.contentRect
592 + const entry = entries[0]
593 + const { width } = entry.contentRect
594
504 - pageSlot.value = width < 700 ? 5 : 8
505 - simpleMode.value = width < 550
595 + pageSlot.value = width < 700 ? 5 : 8
596 + simpleMode.value = width < 550
597 })
598
599 onBeforeMount(() => {
509 - if (!showFilters && preset?.length) {
510 - filters.value = preset
511 - }
600 + if (!showFilters && preset?.length) {
601 + filters.value = preset
602 + }
603
513 - getData()
514 - getAvailableUsers()
515 - getCases()
604 + getData()
605 + getAvailableUsers()
606 + getCases()
607 })
608 </script>