mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-06-07 14:19:45 +00:00
Fixhancement (beta): tasks dismiss all (#12949)
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
|
||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||
</button>
|
||||
<div class="form-check form-switch mb-0 ms-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<unknown>) =>
|
||||
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 = [
|
||||
|
||||
@@ -116,6 +116,20 @@ export class TasksService {
|
||||
)
|
||||
}
|
||||
|
||||
public dismissAllTasks(): Observable<any> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-4
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user