diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index b8e4f3ff5..e1d6bc900 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -84,7 +84,7 @@ diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts b/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts index a87ec49b0..315dc2455 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts @@ -29,7 +29,11 @@ import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' -import { TasksComponent, TaskSection } from './tasks.component' +import { + TaskFilterTargetID, + TasksComponent, + TaskSection, +} from './tasks.component' const tasks: PaperlessTask[] = [ { @@ -154,6 +158,13 @@ const paginatedTasks: Results = { results: tasks, } +const sectionCountResponse = { + all: 7, + needs_attention: 2, + in_progress: 3, + completed: 2, +} + describe('TasksComponent', () => { let component: TasksComponent let fixture: ComponentFixture @@ -221,6 +232,15 @@ describe('TasksComponent', () => { req.params.get('page') === '1' ) .flush(paginatedTasks) + + httpTestingController + .expectOne( + (req) => + req.url === `${environment.apiBaseUrl}tasks/status_counts/` && + req.params.get('acknowledged') === 'false' && + !req.params.has('status') + ) + .flush(sectionCountResponse) }) it('should display task sections with counts', () => { @@ -328,6 +348,74 @@ describe('TasksComponent', () => { expect(pagination).not.toBeNull() }) + it('should apply the selected section to the server-side task query', () => { + component.setSection(TaskSection.NeedsAttention) + + const req = httpTestingController.expectOne( + (request) => + request.url === `${environment.apiBaseUrl}tasks/` && + request.params.get('page') === '1' && + request.params.get('page_size') === '25' && + request.params.get('acknowledged') === 'false' && + request.params.getAll('status').includes(PaperlessTaskStatus.Failure) && + request.params.getAll('status').includes(PaperlessTaskStatus.Revoked) + ) + + req.flush({ count: 2, results: [tasks[0], tasks[1]] }) + expect(component.totalTasks).toBe(2) + }) + + it('should apply task type and trigger source filters to the server-side task query', () => { + component.setTaskType(PaperlessTaskType.SanityCheck) + + httpTestingController + .expectOne( + (request) => + request.url === `${environment.apiBaseUrl}tasks/` && + request.params.get('page_size') === '25' && + request.params.get('task_type') === PaperlessTaskType.SanityCheck + ) + .flush({ count: 1, results: [tasks[6]] }) + + component.setTriggerSource(PaperlessTaskTriggerSource.System) + + httpTestingController + .expectOne( + (request) => + request.url === `${environment.apiBaseUrl}tasks/` && + request.params.get('page_size') === '25' && + request.params.get('task_type') === PaperlessTaskType.SanityCheck && + request.params.get('trigger_source') === + PaperlessTaskTriggerSource.System + ) + .flush({ count: 1, results: [tasks[6]] }) + }) + + it('should apply text filters to the server-side task query', () => { + component.filterText = 'invoice' + jest.advanceTimersByTime(150) + + httpTestingController + .expectOne( + (request) => + request.url === `${environment.apiBaseUrl}tasks/` && + request.params.get('page_size') === '25' && + request.params.get('name') === 'invoice' + ) + .flush({ count: 1, results: [tasks[0]] }) + + component.setFilterTarget(TaskFilterTargetID.Result) + + httpTestingController + .expectOne( + (request) => + request.url === `${environment.apiBaseUrl}tasks/` && + request.params.get('page_size') === '25' && + request.params.get('result') === 'invoice' + ) + .flush({ count: 0, results: [] }) + }) + it('should load a different task page when pagination changes', () => { component.setPage(2) @@ -351,6 +439,27 @@ describe('TasksComponent', () => { expect(component.pagedTasks).toEqual([tasks[0]]) }) + it('should not replace section counts with current-page counts', () => { + component.setPage(2) + + httpTestingController + .expectOne( + (req) => + req.url === `${environment.apiBaseUrl}tasks/` && + req.params.get('acknowledged') === 'false' && + req.params.get('page_size') === '25' && + req.params.get('page') === '2' + ) + .flush({ + count: 30, + results: [tasks[0]], + }) + + expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2) + expect(component.sectionCount(TaskSection.InProgress)).toBe(3) + expect(component.sectionCount(TaskSection.Completed)).toBe(2) + }) + it('should expose stable task type options and disable empty ones', () => { expect(component.taskTypeOptions.map((option) => option.value)).toContain( PaperlessTaskType.TrainClassifier @@ -714,6 +823,9 @@ describe('TasksComponent', () => { }) it('should keep clearing selection independent from resetting filters', () => { + component.resetFilter() + expect(component.filterText).toBe('') + component.setTaskType(PaperlessTaskType.ConsumeFile) component.toggleSelected(tasks[0]) expect(component.selectedTasks.size).toBe(1) diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.ts b/src-ui/src/app/components/admin/tasks/tasks.component.ts index ed72a401d..276dc6a8f 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -40,7 +40,7 @@ export enum TaskSection { Completed = 'completed', } -enum TaskFilterTargetID { +export enum TaskFilterTargetID { Name, Result, } @@ -167,6 +167,12 @@ export class TasksComponent public readonly pageSize = 25 public page: number = 1 public totalTasks: number = 0 + public sectionCounts: Record = { + [TaskSection.All]: 0, + [TaskSection.NeedsAttention]: 0, + [TaskSection.InProgress]: 0, + [TaskSection.Completed]: 0, + } public pagedTasks: PaperlessTask[] = [] public selectedSection: TaskSection = TaskSection.All public selectedTaskType: PaperlessTaskType | null = null @@ -282,6 +288,7 @@ export class TasksComponent .subscribe((query) => { this._filterText = query this.clearSelection() + this.reloadPage(true) }) } @@ -470,9 +477,7 @@ export class TasksComponent } sectionCount(section: TaskSection): number { - return this.pagedTasks.filter((task) => - this.taskBelongsToSection(task, section) - ).length + return this.sectionCounts[section] } sectionShowsResults(section: TaskSection): boolean { @@ -482,16 +487,27 @@ export class TasksComponent setSection(section: TaskSection) { this.selectedSection = section this.clearSelection() + this.reloadPage(true) } setTaskType(taskType: PaperlessTaskType | null) { this.selectedTaskType = taskType this.clearSelection() + this.reloadPage(true) } setTriggerSource(triggerSource: PaperlessTaskTriggerSource | null) { this.selectedTriggerSource = triggerSource this.clearSelection() + this.reloadPage(true) + } + + setFilterTarget(filterTargetID: TaskFilterTargetID) { + this.filterTargetID = filterTargetID + if (this._filterText.length) { + this.clearSelection() + this.reloadPage(true) + } } taskTypeOptionCount(taskType: PaperlessTaskType | null): number { @@ -529,19 +545,32 @@ export class TasksComponent } public resetFilter() { + if (!this._filterText.length) { + return + } + this._filterText = '' + this.clearSelection() + this.reloadPage(true) } public resetFilters() { + const hadFilter = this.isFiltered this.selectedTaskType = null this.selectedTriggerSource = null - this.resetFilter() + this._filterText = '' this.clearSelection() + + if (hadFilter) { + this.reloadPage(true) + } } filterInputKeyup(event: KeyboardEvent) { if (event.key == 'Enter') { this._filterText = (event.target as HTMLInputElement).value + this.clearSelection() + this.reloadPage(true) } else if (event.key === 'Escape') { this.resetFilter() } @@ -630,19 +659,86 @@ export class TasksComponent ) } + private reloadSectionCounts() { + this.tasksService + .statusCounts(this.getParamsForSection(TaskSection.All)) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe((counts) => { + this.sectionCounts[TaskSection.All] = counts.all + this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention + this.sectionCounts[TaskSection.InProgress] = counts.in_progress + this.sectionCounts[TaskSection.Completed] = counts.completed + }) + } + + private getParamsForSection( + section: TaskSection + ): Record { + const params: Record< + string, + string | number | boolean | readonly string[] + > = { + acknowledged: false, + } + + const statuses = this.statusesForSection(section) + if (statuses.length) { + params.status = statuses + } + + if (this.selectedTaskType !== null) { + params.task_type = this.selectedTaskType + } + + if (this.selectedTriggerSource !== null) { + params.trigger_source = this.selectedTriggerSource + } + + if (this._filterText.length) { + params[ + this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result' + ] = this._filterText + } + + return params + } + + private statusesForSection(section: TaskSection): PaperlessTaskStatus[] { + switch (section) { + case TaskSection.NeedsAttention: + return [PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked] + case TaskSection.InProgress: + return [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started] + case TaskSection.Completed: + return [PaperlessTaskStatus.Success] + default: + return [] + } + } + private reloadPage(resetToFirstPage: boolean = false) { if (resetToFirstPage) { this.page = 1 } + this.reloadSectionCounts() + this.loading = true this.tasksService - .list(this.page, this.pageSize, { acknowledged: false }) + .list( + this.page, + this.pageSize, + this.getParamsForSection(this.selectedSection) + ) .pipe(first(), takeUntil(this.unsubscribeNotifier)) .subscribe({ next: (result) => { this.pagedTasks = result.results this.totalTasks = result.count + this.sectionCounts[TaskSection.All] = result.count + if (this.selectedSection !== TaskSection.All) { + this.sectionCounts[this.selectedSection] = result.count + } this.loading = false if ( this.page > 1 && diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index 53aba0edd..ca64918c4 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -64,3 +64,10 @@ export interface PaperlessTaskSummary { last_success: Date | null last_failure: Date | null } + +export interface PaperlessTaskStatusCounts { + all: number + needs_attention: number + in_progress: number + completed: number +} diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts index 1ae217543..3412ae2ce 100644 --- a/src-ui/src/app/services/tasks.service.spec.ts +++ b/src-ui/src/app/services/tasks.service.spec.ts @@ -242,4 +242,34 @@ describe('TasksService', () => { task_id: 'abc-123', }) }) + + it('loads filtered task status counts', () => { + tasksService + .statusCounts({ + acknowledged: false, + task_type: PaperlessTaskType.ConsumeFile, + }) + .subscribe((res) => { + expect(res).toEqual({ + all: 10, + needs_attention: 2, + in_progress: 3, + completed: 5, + }) + }) + + const req = httpTestingController.expectOne( + (req: HttpRequest) => + req.url === `${environment.apiBaseUrl}tasks/status_counts/` && + req.params.get('acknowledged') === 'false' && + req.params.get('task_type') === PaperlessTaskType.ConsumeFile + ) + expect(req.request.method).toEqual('GET') + req.flush({ + all: 10, + needs_attention: 2, + in_progress: 3, + completed: 5, + }) + }) }) diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index a3ae283ed..404db589a 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -5,6 +5,7 @@ import { first, map, takeUntil, tap } from 'rxjs/operators' import { PaperlessTask, PaperlessTaskStatus, + PaperlessTaskStatusCounts, PaperlessTaskType, } from 'src/app/data/paperless-task' import { Results } from 'src/app/data/results' @@ -88,7 +89,7 @@ export class TasksService { public list( page: number, pageSize: number, - extraParams?: Record + extraParams?: Record ): Observable> { return this.http.get>( `${this.baseUrl}${this.endpoint}/`, @@ -102,6 +103,17 @@ export class TasksService { ) } + public statusCounts( + extraParams?: Record + ): Observable { + return this.http.get( + `${this.baseUrl}${this.endpoint}/status_counts/`, + { + params: extraParams, + } + ) + } + public dismissTasks(task_ids: Set): Observable { return this.http .post(`${this.baseUrl}tasks/acknowledge/`, { diff --git a/src/documents/filters.py b/src/documents/filters.py index ddc784204..39c6eb467 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -28,6 +28,7 @@ from django.db.models.functions import Cast from django.utils.translation import gettext_lazy as _ from django_filters import DateFilter from django_filters.rest_framework import BooleanFilter +from django_filters.rest_framework import CharFilter from django_filters.rest_framework import DateTimeFilter from django_filters.rest_framework import Filter from django_filters.rest_framework import FilterSet @@ -900,6 +901,16 @@ class ShareLinkBundleFilterSet(FilterSet): class PaperlessTaskFilterSet(FilterSet): + name = CharFilter( + method="filter_name", + label="Name", + ) + + result = CharFilter( + method="filter_result", + label="Result", + ) + task_type = MultipleChoiceFilter( choices=PaperlessTask.TaskType.choices, label="Task Type", @@ -939,7 +950,58 @@ class PaperlessTaskFilterSet(FilterSet): class Meta: model = PaperlessTask - fields = ["task_type", "trigger_source", "status", "acknowledged", "owner"] + fields = [ + "task_type", + "trigger_source", + "status", + "acknowledged", + "owner", + "name", + "result", + ] + + def filter_name(self, queryset, name, value): + if not value: + return queryset + + matching_task_types = [ + task_type + for task_type, label in PaperlessTask.TaskType.choices + if value.lower() in str(label).lower() + ] + matching_trigger_sources = [ + trigger_source + for trigger_source, label in PaperlessTask.TriggerSource.choices + if value.lower() in str(label).lower() + ] + + return queryset.filter( + Q(input_data__filename__icontains=value) + | Q(task_type__in=matching_task_types) + | Q(trigger_source__in=matching_trigger_sources), + ) + + def filter_result(self, queryset, name, value): + if not value: + return queryset + + query = Q(result_data__reason__icontains=value) | Q( + result_data__error_message__icontains=value, + ) + + try: + numeric_value = int(value) + except (TypeError, ValueError): + pass + else: + query |= Q(result_data__document_id=numeric_value) | Q( + result_data__duplicate_of=numeric_value, + ) + + if "duplicate" in value.lower(): + query |= Q(result_data__duplicate_of__isnull=False) + + return queryset.filter(query) def filter_is_complete(self, queryset, name, value): if value: diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index 42ccbab5c..59767f9af 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -18,6 +18,7 @@ from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APIClient +from documents.filters import PaperlessTaskFilterSet from documents.models import PaperlessTask from documents.tests.factories import DocumentFactory from documents.tests.factories import PaperlessTaskFactory @@ -169,6 +170,165 @@ class TestGetTasksV10: PaperlessTask.Status.STARTED, } + def test_filter_by_task_name(self, admin_client: APIClient) -> None: + """?name= searches task filenames, task types, and trigger sources.""" + filename_task = PaperlessTaskFactory(input_data={"filename": "invoice-123.pdf"}) + type_task = PaperlessTaskFactory(task_type=PaperlessTask.TaskType.SANITY_CHECK) + source_task = PaperlessTaskFactory( + trigger_source=PaperlessTask.TriggerSource.EMAIL_CONSUME, + ) + PaperlessTaskFactory(input_data={"filename": "unrelated.pdf"}) + + response = admin_client.get(ENDPOINT, {"name": "invoice"}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["task_id"] == filename_task.task_id + + response = admin_client.get(ENDPOINT, {"name": "sanity"}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["task_id"] == type_task.task_id + + response = admin_client.get(ENDPOINT, {"name": "email"}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["task_id"] == source_task.task_id + + def test_filter_by_task_result(self, admin_client: APIClient) -> None: + """?result= searches common structured task result messages.""" + reason_task = PaperlessTaskFactory(result_data={"reason": "Manual review"}) + error_task = PaperlessTaskFactory( + result_data={"error_message": "Duplicate detected"}, + ) + document_task = PaperlessTaskFactory(result_data={"document_id": 321}) + duplicate_task = PaperlessTaskFactory(result_data={"duplicate_of": 123}) + PaperlessTaskFactory(result_data={"reason": "unrelated"}) + + response = admin_client.get(ENDPOINT, {"result": "manual"}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["task_id"] == reason_task.task_id + + response = admin_client.get(ENDPOINT, {"result": "duplicate"}) + + assert response.status_code == status.HTTP_200_OK + returned_ids = {task["task_id"] for task in response.data["results"]} + assert returned_ids == {error_task.task_id, duplicate_task.task_id} + + response = admin_client.get(ENDPOINT, {"result": "321"}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["task_id"] == document_task.task_id + + def test_empty_task_name_and_result_filters(self) -> None: + """Empty name/result values leave the queryset unchanged.""" + PaperlessTaskFactory.create_batch(2) + queryset = PaperlessTask.objects.all() + filterset = PaperlessTaskFilterSet() + + assert filterset.filter_name(queryset, "name", "").count() == 2 + assert filterset.filter_result(queryset, "result", "").count() == 2 + + def test_status_counts_respects_filters(self, admin_client: APIClient) -> None: + """status_counts/ returns section counts for the filtered task queryset.""" + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.FAILURE, + input_data={"filename": "invoice-a.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.REVOKED, + input_data={"filename": "invoice-b.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.PENDING, + input_data={"filename": "invoice-c.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.STARTED, + input_data={"filename": "invoice-d.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.SUCCESS, + input_data={"filename": "invoice-e.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=True, + status=PaperlessTask.Status.SUCCESS, + input_data={"filename": "invoice-acknowledged.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.SUCCESS, + input_data={"filename": "unrelated.pdf"}, + ) + + response = admin_client.get( + f"{ENDPOINT}status_counts/", + {"acknowledged": "false", "name": "invoice"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "all": 5, + "needs_attention": 2, + "in_progress": 2, + "completed": 1, + } + + def test_status_counts_ignores_section_filters( + self, + admin_client: APIClient, + ) -> None: + """status_counts/ ignores status-like filters for the sections it counts.""" + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.FAILURE, + input_data={"filename": "invoice-a.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.PENDING, + input_data={"filename": "invoice-b.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.SUCCESS, + input_data={"filename": "invoice-c.pdf"}, + ) + PaperlessTaskFactory( + acknowledged=False, + status=PaperlessTask.Status.FAILURE, + input_data={"filename": "unrelated.pdf"}, + ) + + response = admin_client.get( + f"{ENDPOINT}status_counts/", + { + "acknowledged": "false", + "name": "invoice", + "status": PaperlessTask.Status.FAILURE, + "is_complete": "false", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "all": 3, + "needs_attention": 1, + "in_progress": 1, + "completed": 1, + } + def test_default_ordering_is_newest_first(self, admin_client: APIClient) -> None: """Tasks are returned in descending date_created order (newest first).""" base = timezone.now() diff --git a/src/documents/views.py b/src/documents/views.py index ba4faa622..cbc4560d8 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -4011,7 +4011,7 @@ class RemoteVersionView(GenericAPIView[Any]): class _TasksViewSetSchema(AutoSchema): - _UNPAGINATED_ACTIONS = frozenset({"summary", "active"}) + _UNPAGINATED_ACTIONS = frozenset({"summary", "active", "status_counts"}) def _get_paginator(self): if getattr(self.view, "action", None) in self._UNPAGINATED_ACTIONS: @@ -4071,6 +4071,19 @@ class _TasksViewSetSchema(AutoSchema): ), ], ), + status_counts=extend_schema( + responses={ + 200: inline_serializer( + name="TaskStatusCounts", + fields={ + "all": serializers.IntegerField(), + "needs_attention": serializers.IntegerField(), + "in_progress": serializers.IntegerField(), + "completed": serializers.IntegerField(), + }, + ), + }, + ), active=extend_schema( description="Currently pending and running tasks (capped at 50).", responses={200: TaskSerializerV10(many=True)}, @@ -4124,6 +4137,7 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): PaperlessTask.TaskType.SANITY_CHECK: (sanity_check, {"raise_on_error": False}), PaperlessTask.TaskType.LLM_INDEX: (llmindex_index, {"rebuild": False}), } + _STATUS_COUNT_EXCLUDED_FILTERS = frozenset({"status", "is_complete"}) def get_serializer_class(self): # v9: use backwards-compatible serializer with old field names @@ -4164,6 +4178,21 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): queryset = queryset.filter(task_id=task_id) return queryset + def get_status_count_queryset(self): + """Apply task filters except the status dimensions represented by the counts.""" + query_params = self.request.query_params.copy() + for param in self._STATUS_COUNT_EXCLUDED_FILTERS: + query_params.pop(param, None) + + filterset = self.filterset_class( + data=query_params, + queryset=self.get_queryset(), + request=self.request, + ) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + @action( methods=["post"], detail=False, @@ -4233,6 +4262,34 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): serializer = TaskSummarySerializer(data, many=True) return Response(serializer.data) + @action(methods=["get"], detail=False) + def status_counts(self, request): + """Aggregated task counts for task UI sections.""" + queryset = self.get_status_count_queryset() + counts = queryset.aggregate( + all=Count("id"), + needs_attention=Count( + "id", + filter=Q( + status__in=[ + PaperlessTask.Status.FAILURE, + PaperlessTask.Status.REVOKED, + ], + ), + ), + in_progress=Count( + "id", + filter=Q( + status__in=[ + PaperlessTask.Status.PENDING, + PaperlessTask.Status.STARTED, + ], + ), + ), + completed=Count("id", filter=Q(status=PaperlessTask.Status.SUCCESS)), + ) + return Response(counts) + @action(methods=["get"], detail=False) def active(self, request): """Currently pending and running tasks (capped at 50)."""