From 3d0b8343b9d315598f9675ee51b0f0bb7e9921bb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:42:06 -0700 Subject: [PATCH] Fixhancement (beta): tasks dismiss all (#12949) --- .../admin/tasks/tasks.component.html | 3 ++ .../admin/tasks/tasks.component.spec.ts | 43 ++++++++++++++++++- .../components/admin/tasks/tasks.component.ts | 24 +++++++++++ src-ui/src/app/services/tasks.service.spec.ts | 21 +++++++++ src-ui/src/app/services/tasks.service.ts | 14 ++++++ src/documents/serialisers.py | 26 ++++++++++- src/documents/tests/test_api_tasks.py | 21 +++++++++ src/documents/views.py | 15 +++++-- 8 files changed, 160 insertions(+), 7 deletions(-) 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 116d35f89..b8e4f3ff5 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -11,6 +11,9 @@ +
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 962895295..a87ec49b0 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 @@ -11,7 +11,7 @@ import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { throwError } from 'rxjs' +import { of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' import { PaperlessTask, @@ -295,6 +295,7 @@ describe('TasksComponent', () => { const headerText = header.nativeElement.textContent expect(headerText).toContain('Dismiss visible') + expect(headerText).toContain('Dismiss all') expect(headerText).toContain('Auto refresh') expect(headerText).not.toContain('All types') expect(headerText).not.toContain('All sources') @@ -495,6 +496,46 @@ describe('TasksComponent', () => { expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466])) }) + it('should support dismiss all tasks', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) + const dismissSpy = jest + .spyOn(tasksService, 'dismissAllTasks') + .mockReturnValue(of({})) + const reloadPageSpy = jest + .spyOn(component as any, 'reloadPage') + .mockImplementation(() => undefined) + + component.dismissAllTasks() + + expect(modal).not.toBeUndefined() + expect(modal.componentInstance.messageBold).toBe('Dismiss all 7 tasks?') + modal.componentInstance.confirmClicked.emit() + expect(dismissSpy).toHaveBeenCalled() + expect(reloadPageSpy).toHaveBeenCalledWith(false) + expect(component.selectedTasks.size).toBe(0) + }) + + it('should show an error and re-enable modal buttons when dismissing all tasks fails', () => { + const error = new Error('dismiss all failed') + const toastSpy = jest.spyOn(toastService, 'showError') + const dismissSpy = jest + .spyOn(tasksService, 'dismissAllTasks') + .mockReturnValue(throwError(() => error)) + + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) + + component.dismissAllTasks() + expect(modal).not.toBeUndefined() + + modal.componentInstance.confirmClicked.emit() + + expect(dismissSpy).toHaveBeenCalled() + expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) + expect(modal.componentInstance.buttonsEnabled).toBe(true) + }) + it('should dismiss the currently visible scoped and filtered tasks', () => { component.setSection(TaskSection.InProgress) component.setTaskType(PaperlessTaskType.SanityCheck) 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 884ede0d6..ed72a401d 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -334,6 +334,30 @@ export class TasksComponent } } + dismissAllTasks() { + let modal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.title = $localize`Confirm Dismiss All` + modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?` + modal.componentInstance.btnClass = 'btn-warning' + modal.componentInstance.btnCaption = $localize`Dismiss` + modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { + modal.componentInstance.buttonsEnabled = false + modal.close() + this.tasksService.dismissAllTasks().subscribe({ + next: () => { + this.reloadPage(false) + }, + error: (e) => { + this.toastService.showError($localize`Error dismissing tasks`, e) + modal.componentInstance.buttonsEnabled = true + }, + }) + this.clearSelection() + }) + } + expandTask(task: PaperlessTask) { this.expandedTask = this.expandedTask == task.id ? undefined : task.id } diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts index 3cc35232d..1ae217543 100644 --- a/src-ui/src/app/services/tasks.service.spec.ts +++ b/src-ui/src/app/services/tasks.service.spec.ts @@ -80,6 +80,27 @@ describe('TasksService', () => { .flush({ count: 0, results: [] }) }) + it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => { + tasksService.dismissAllTasks().subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}tasks/acknowledge/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + all: true, + }) + req.flush([]) + // reload is then called + httpTestingController + .expectOne( + (req: HttpRequest) => + req.url === `${environment.apiBaseUrl}tasks/` && + req.params.get('acknowledged') === 'false' && + req.params.get('page_size') === '1000' + ) + .flush({ count: 0, results: [] }) + }) + it('groups mixed task types by status when reloading', () => { expect(tasksService.total).toEqual(0) const mockTasks = [ diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index 1eb5e0837..a3ae283ed 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -116,6 +116,20 @@ export class TasksService { ) } + public dismissAllTasks(): Observable { + return this.http + .post(`${this.baseUrl}tasks/acknowledge/`, { + all: true, + }) + .pipe( + first(), + takeUntil(this.unsubscribeNotifer), + tap(() => { + this.reload() + }) + ) + } + public cancelPending(): void { this.unsubscribeNotifer.next(true) } diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1ff3798db..82c18b703 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2632,18 +2632,25 @@ class RunTaskSerializer(serializers.Serializer[dict[str, str]]): class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]): tasks = serializers.ListField( - required=True, + required=False, label="Tasks", write_only=True, child=serializers.IntegerField(), ) + all = serializers.BooleanField( + required=False, + default=False, + label="All", + write_only=True, + ) def _validate_task_id_list(self, tasks, name="tasks") -> None: if not isinstance(tasks, list): raise serializers.ValidationError(f"{name} must be a list") if not all(isinstance(i, int) for i in tasks): raise serializers.ValidationError(f"{name} must be a list of integers") - count = PaperlessTask.objects.filter(id__in=tasks).count() + queryset = self.context.get("queryset", PaperlessTask.objects.all()) + count = queryset.filter(id__in=tasks).count() if not count == len(tasks): raise serializers.ValidationError( f"Some tasks in {name} don't exist or were specified twice.", @@ -2653,6 +2660,21 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]): self._validate_task_id_list(tasks) return tasks + def validate(self, attrs): + acknowledge_all = attrs.get("all", False) + task_ids = attrs.get("tasks") + + if acknowledge_all and task_ids is not None: + raise serializers.ValidationError( + "Set either all or tasks, not both.", + ) + if not acknowledge_all and task_ids is None: + raise serializers.ValidationError( + "Either all must be true or tasks must be provided.", + ) + + return attrs + class ShareLinkSerializer(OwnedObjectSerializer): class Meta: diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index f9b6c4538..42ccbab5c 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -522,6 +522,27 @@ class TestAcknowledge: assert response.status_code == status.HTTP_200_OK assert response.data == {"result": 2} + def test_acknowledge_all_returns_count(self, admin_client: APIClient) -> None: + """POST acknowledge/ with all=true acknowledges all unacknowledged tasks.""" + unacknowledged_task1 = PaperlessTaskFactory(acknowledged=False) + unacknowledged_task2 = PaperlessTaskFactory(acknowledged=False) + acknowledged_task = PaperlessTaskFactory(acknowledged=True) + + response = admin_client.post( + ENDPOINT + "acknowledge/", + {"all": True}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"result": 2} + unacknowledged_task1.refresh_from_db() + unacknowledged_task2.refresh_from_db() + acknowledged_task.refresh_from_db() + assert unacknowledged_task1.acknowledged + assert unacknowledged_task2.acknowledged + assert acknowledged_task.acknowledged + def test_acknowledged_tasks_excluded_from_unacked_filter( self, admin_client: APIClient, diff --git a/src/documents/views.py b/src/documents/views.py index 4fc0b3f51..ba4faa622 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -4033,7 +4033,7 @@ class _TasksViewSetSchema(AutoSchema): ), acknowledge=extend_schema( operation_id="acknowledge_tasks", - description="Acknowledge a list of tasks", + description="Acknowledge a list of tasks, or all visible unacknowledged tasks", request=AcknowledgeTasksViewSerializer, responses={ (200, "application/json"): inline_serializer( @@ -4170,10 +4170,17 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions], ) def acknowledge(self, request): - serializer = AcknowledgeTasksViewSerializer(data=request.data) + queryset = self.get_queryset() + serializer = AcknowledgeTasksViewSerializer( + data=request.data, + context={"queryset": queryset}, + ) serializer.is_valid(raise_exception=True) - task_ids = serializer.validated_data.get("tasks") - tasks = self.get_queryset().filter(id__in=task_ids) + if serializer.validated_data.get("all", False): + tasks = queryset.filter(acknowledged=False) + else: + task_ids = serializer.validated_data.get("tasks") + tasks = queryset.filter(id__in=task_ids) count = tasks.update(acknowledged=True) return Response({"result": count})