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 8fd5ce174..256689e1c 100644
--- a/src-ui/src/app/components/admin/tasks/tasks.component.html
+++ b/src-ui/src/app/components/admin/tasks/tasks.component.html
@@ -98,6 +98,18 @@
Reset filters
}
+
+
+
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 161f3cdbd..f6f3405bf 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
@@ -19,6 +19,7 @@ import {
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
+import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -148,6 +149,11 @@ const tasks: PaperlessTask[] = [
},
]
+const paginatedTasks: Results = {
+ count: tasks.length,
+ results: tasks,
+}
+
describe('TasksComponent', () => {
let component: TasksComponent
let fixture: ComponentFixture
@@ -196,9 +202,25 @@ describe('TasksComponent', () => {
component = fixture.componentInstance
jest.useFakeTimers()
fixture.detectChanges()
+
httpTestingController
- .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
- .flush(tasks)
+ .expectOne(
+ (req) =>
+ req.url === `${environment.apiBaseUrl}tasks/` &&
+ req.params.get('acknowledged') === 'false' &&
+ req.params.get('page_size') === '1000'
+ )
+ .flush(paginatedTasks)
+
+ httpTestingController
+ .expectOne(
+ (req) =>
+ req.url === `${environment.apiBaseUrl}tasks/` &&
+ req.params.get('acknowledged') === 'false' &&
+ req.params.get('page_size') === '25' &&
+ req.params.get('page') === '1'
+ )
+ .flush(paginatedTasks)
})
it('should display task sections with counts', () => {
@@ -294,6 +316,40 @@ describe('TasksComponent', () => {
).toBeTruthy()
})
+ it('should render pagination controls next to the task filter', () => {
+ fixture.detectChanges()
+
+ const controls = fixture.debugElement.query(By.css('.task-controls'))
+ const search = controls.query(By.css('.task-search'))
+ const pagination = controls.query(By.css('ngb-pagination'))
+
+ expect(search).not.toBeNull()
+ expect(pagination).not.toBeNull()
+ })
+
+ it('should load a different task page when pagination changes', () => {
+ component.setPage(2)
+
+ const pageTwoTasks = {
+ count: 30,
+ results: [tasks[0]],
+ }
+
+ 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(pageTwoTasks)
+
+ expect(component.page).toBe(2)
+ expect(component.totalTasks).toBe(30)
+ expect(component.pagedTasks).toEqual([tasks[0]])
+ })
+
it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier
@@ -470,7 +526,7 @@ describe('TasksComponent', () => {
component.toggleSection(TaskSection.NeedsAttention, {
target: { checked: false },
- } as PointerEvent)
+ } as unknown as PointerEvent)
expect(component.selectedTasks).toEqual(new Set())
})
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 7e8155bac..d9a57edce 100644
--- a/src-ui/src/app/components/admin/tasks/tasks.component.ts
+++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts
@@ -6,6 +6,7 @@ import {
NgbCollapseModule,
NgbDropdownModule,
NgbModal,
+ NgbPaginationModule,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -139,6 +140,7 @@ const TRIGGER_SOURCE_OPTIONS: Array<{
NgTemplateOutlet,
NgbCollapseModule,
NgbDropdownModule,
+ NgbPaginationModule,
NgbPopoverModule,
NgxBootstrapIconsModule,
],
@@ -161,6 +163,10 @@ export class TasksComponent
public selectedTasks: Set = new Set()
public expandedTask: number
public autoRefreshEnabled: boolean = true
+ public readonly pageSize = 25
+ public page: number = 1
+ public totalTasks: number = 0
+ public pagedTasks: PaperlessTask[] = []
public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
@@ -254,6 +260,7 @@ export class TasksComponent
ngOnInit() {
this.tasksService.reload()
+ this.reloadPage()
timer(5000, 5000)
.pipe(
filter(() => this.autoRefreshEnabled),
@@ -261,6 +268,7 @@ export class TasksComponent
)
.subscribe(() => {
this.tasksService.reload()
+ this.reloadPage(false)
})
this.filterDebounce
@@ -270,7 +278,10 @@ export class TasksComponent
distinctUntilChanged(),
filter((query) => !query.length || query.length > 2)
)
- .subscribe((query) => (this._filterText = query))
+ .subscribe((query) => {
+ this._filterText = query
+ this.clearSelection()
+ })
}
ngOnDestroy() {
@@ -300,6 +311,9 @@ export class TasksComponent
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissTasks(tasks).subscribe({
+ next: () => {
+ this.reloadPage(false)
+ },
error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled = true
@@ -309,6 +323,9 @@ export class TasksComponent
})
} else if (tasks.size === 1) {
this.tasksService.dismissTasks(tasks).subscribe({
+ next: () => {
+ this.reloadPage(false)
+ },
error: (e) =>
this.toastService.showError($localize`Error dismissing task`, e),
})
@@ -421,7 +438,7 @@ export class TasksComponent
}
tasksForSection(section: TaskSection): PaperlessTask[] {
- let tasks = this.tasksService.allFileTasks.filter((task) =>
+ let tasks = this.pagedTasks.filter((task) =>
this.taskBelongsToSection(task, section)
)
@@ -433,7 +450,7 @@ export class TasksComponent
}
sectionCount(section: TaskSection): number {
- return this.tasksService.allFileTasks.filter((task) =>
+ return this.pagedTasks.filter((task) =>
this.taskBelongsToSection(task, section)
).length
}
@@ -481,6 +498,16 @@ export class TasksComponent
this.selectedTasks.clear()
}
+ setPage(page: number) {
+ if (this.page === page) {
+ return
+ }
+
+ this.page = page
+ this.clearSelection()
+ this.reloadPage()
+ }
+
public resetFilter() {
this._filterText = ''
}
@@ -576,10 +603,39 @@ export class TasksComponent
? this.sections
: [this.selectedSection]
- return this.tasksService.allFileTasks.filter(
+ return this.pagedTasks.filter(
(task) =>
sections.some((section) => this.taskBelongsToSection(task, section)) &&
this.taskMatchesFilters(task, { taskType, triggerSource })
)
}
+
+ private reloadPage(resetToFirstPage: boolean = false) {
+ if (resetToFirstPage) {
+ this.page = 1
+ }
+
+ this.loading = true
+ this.tasksService
+ .list(this.page, this.pageSize, { acknowledged: false })
+ .pipe(first(), takeUntil(this.unsubscribeNotifier))
+ .subscribe({
+ next: (result) => {
+ this.pagedTasks = result.results
+ this.totalTasks = result.count
+ this.loading = false
+ if (
+ this.page > 1 &&
+ this.pagedTasks.length === 0 &&
+ this.totalTasks > 0
+ ) {
+ this.page -= 1
+ this.reloadPage()
+ }
+ },
+ error: () => {
+ this.loading = false
+ },
+ })
+ }
}
diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts
index 922ab103f..3cc35232d 100644
--- a/src-ui/src/app/services/tasks.service.spec.ts
+++ b/src-ui/src/app/services/tasks.service.spec.ts
@@ -1,4 +1,8 @@
-import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import {
+ HttpRequest,
+ provideHttpClient,
+ withInterceptorsFromDi,
+} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
@@ -37,16 +41,21 @@ describe('TasksService', () => {
it('calls tasks api endpoint on reload', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
- `${environment.apiBaseUrl}tasks/?acknowledged=false`
+ (req: HttpRequest) =>
+ req.url === `${environment.apiBaseUrl}tasks/` &&
+ req.params.get('acknowledged') === 'false' &&
+ req.params.get('page_size') === '1000'
)
expect(req.request.method).toEqual('GET')
+ req.flush({ count: 0, results: [] })
})
it('does not call tasks api endpoint on reload if already loading', () => {
tasksService.loading = true
tasksService.reload()
httpTestingController.expectNone(
- `${environment.apiBaseUrl}tasks/?acknowledged=false`
+ (req: HttpRequest) =>
+ req.url === `${environment.apiBaseUrl}tasks/`
)
})
@@ -62,8 +71,13 @@ describe('TasksService', () => {
req.flush([])
// reload is then called
httpTestingController
- .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
- .flush([])
+ .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', () => {
@@ -124,10 +138,13 @@ describe('TasksService', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
- `${environment.apiBaseUrl}tasks/?acknowledged=false`
+ (req: HttpRequest) =>
+ req.url === `${environment.apiBaseUrl}tasks/` &&
+ req.params.get('acknowledged') === 'false' &&
+ req.params.get('page_size') === '1000'
)
- req.flush(mockTasks)
+ req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.allFileTasks).toHaveLength(5)
expect(tasksService.completedFileTasks).toHaveLength(2)
@@ -173,10 +190,13 @@ describe('TasksService', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
- `${environment.apiBaseUrl}tasks/?acknowledged=false`
+ (req: HttpRequest) =>
+ req.url === `${environment.apiBaseUrl}tasks/` &&
+ req.params.get('acknowledged') === 'false' &&
+ req.params.get('page_size') === '1000'
)
- req.flush(mockTasks)
+ req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.needsAttentionTasks).toHaveLength(2)
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts
index c598d2b73..1eb5e0837 100644
--- a/src-ui/src/app/services/tasks.service.ts
+++ b/src-ui/src/app/services/tasks.service.ts
@@ -1,12 +1,13 @@
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { Observable, Subject } from 'rxjs'
-import { first, takeUntil, tap } from 'rxjs/operators'
+import { first, map, takeUntil, tap } from 'rxjs/operators'
import {
PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
+import { Results } from 'src/app/data/results'
import { environment } from 'src/environments/environment'
@Injectable({
@@ -17,6 +18,7 @@ export class TasksService {
private baseUrl: string = environment.apiBaseUrl
private endpoint: string = 'tasks'
+ private readonly defaultReloadPageSize = 1000
public loading: boolean = false
@@ -69,9 +71,13 @@ export class TasksService {
this.loading = true
this.http
- .get(
- `${this.baseUrl}${this.endpoint}/?acknowledged=false`
- )
+ .get>(`${this.baseUrl}${this.endpoint}/`, {
+ params: {
+ acknowledged: 'false',
+ page_size: this.defaultReloadPageSize,
+ },
+ })
+ .pipe(map((r) => r.results))
.pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => {
this.fileTasks = r
@@ -79,6 +85,23 @@ export class TasksService {
})
}
+ public list(
+ page: number,
+ pageSize: number,
+ extraParams?: Record
+ ): Observable> {
+ return this.http.get>(
+ `${this.baseUrl}${this.endpoint}/`,
+ {
+ params: {
+ page,
+ page_size: pageSize,
+ ...extraParams,
+ },
+ }
+ )
+ }
+
public dismissTasks(task_ids: Set): Observable {
return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, {
diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py
index b85622463..b096a336e 100644
--- a/src/documents/tests/test_api_tasks.py
+++ b/src/documents/tests/test_api_tasks.py
@@ -31,6 +31,20 @@ ACCEPT_V9 = "application/json; version=9"
@pytest.mark.django_db()
class TestGetTasksV10:
+ def test_list_response_has_paginated_structure(
+ self,
+ admin_client: APIClient,
+ ) -> None:
+ """GET /api/tasks/ returns a paginated envelope with count and results."""
+ PaperlessTaskFactory.create_batch(3)
+
+ response = admin_client.get(ENDPOINT)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert "count" in response.data
+ assert "results" in response.data
+ assert response.data["count"] == 3
+
def test_list_returns_tasks(self, admin_client: APIClient) -> None:
"""GET /api/tasks/ returns all tasks visible to the admin."""
PaperlessTaskFactory.create_batch(2)
@@ -38,7 +52,7 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
+ assert response.data["count"] == 2
def test_related_document_ids_populated_from_result_data(
self,
@@ -53,7 +67,7 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["related_document_ids"] == [7]
+ assert response.data["results"][0]["related_document_ids"] == [7]
def test_related_document_ids_includes_duplicate_of(
self,
@@ -68,7 +82,7 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["related_document_ids"] == [12]
+ assert response.data["results"][0]["related_document_ids"] == [12]
def test_filter_by_task_type(self, admin_client: APIClient) -> None:
"""?task_type= filters results to tasks of that type only."""
@@ -81,8 +95,11 @@ class TestGetTasksV10:
)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["task_type"] == PaperlessTask.TaskType.TRAIN_CLASSIFIER
+ assert response.data["count"] == 1
+ assert (
+ response.data["results"][0]["task_type"]
+ == PaperlessTask.TaskType.TRAIN_CLASSIFIER
+ )
def test_filter_by_status(self, admin_client: APIClient) -> None:
"""?status= filters results to tasks with that status only."""
@@ -95,8 +112,8 @@ class TestGetTasksV10:
)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["status"] == PaperlessTask.Status.SUCCESS
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["status"] == PaperlessTask.Status.SUCCESS
def test_filter_by_task_id(self, admin_client: APIClient) -> None:
"""?task_id= returns only the task with that UUID."""
@@ -106,8 +123,8 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT, {"task_id": task.task_id})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["task_id"] == task.task_id
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["task_id"] == task.task_id
def test_filter_by_acknowledged(self, admin_client: APIClient) -> None:
"""?acknowledged=false returns only tasks that have not been acknowledged."""
@@ -117,8 +134,8 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT, {"acknowledged": "false"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["acknowledged"] is False
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["acknowledged"] is False
def test_filter_is_complete_true(self, admin_client: APIClient) -> None:
"""?is_complete=true returns only SUCCESS and FAILURE tasks."""
@@ -129,8 +146,8 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT, {"is_complete": "true"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
- returned_statuses = {t["status"] for t in response.data}
+ assert response.data["count"] == 2
+ returned_statuses = {t["status"] for t in response.data["results"]}
assert returned_statuses == {
PaperlessTask.Status.SUCCESS,
PaperlessTask.Status.FAILURE,
@@ -145,8 +162,8 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT, {"is_complete": "false"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
- returned_statuses = {t["status"] for t in response.data}
+ assert response.data["count"] == 2
+ returned_statuses = {t["status"] for t in response.data["results"]}
assert returned_statuses == {
PaperlessTask.Status.PENDING,
PaperlessTask.Status.STARTED,
@@ -162,7 +179,7 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- ids = [t["task_id"] for t in response.data]
+ ids = [t["task_id"] for t in response.data["results"]]
assert ids == [t3.task_id, t2.task_id, t1.task_id]
def test_list_scoped_to_own_and_unowned_tasks_for_regular_user(
@@ -186,8 +203,8 @@ class TestGetTasksV10:
response = client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
- visible_ids = {t["task_id"] for t in response.data}
+ assert response.data["count"] == 2
+ visible_ids = {t["task_id"] for t in response.data["results"]}
assert visible_ids == {own_task.task_id, unowned_task.task_id}
def test_list_admin_sees_all_tasks(
@@ -204,7 +221,7 @@ class TestGetTasksV10:
response = admin_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 3
+ assert response.data["count"] == 3
@pytest.mark.django_db()
@@ -241,7 +258,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["task_name"] == expected_task_name
+ assert response.data["results"][0]["task_name"] == expected_task_name
@pytest.mark.parametrize(
("trigger_source", "expected_type"),
@@ -295,7 +312,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["type"] == expected_type
+ assert response.data["results"][0]["type"] == expected_type
def test_task_file_name_from_input_data(self, v9_client: APIClient) -> None:
"""task_file_name is read from input_data['filename']."""
@@ -304,7 +321,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["task_file_name"] == "report.pdf"
+ assert response.data["results"][0]["task_file_name"] == "report.pdf"
def test_task_file_name_none_when_no_filename_key(
self,
@@ -316,7 +333,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["task_file_name"] is None
+ assert response.data["results"][0]["task_file_name"] is None
def test_related_document_from_result_data_document_id(
self,
@@ -331,7 +348,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["related_document"] == 99
+ assert response.data["results"][0]["related_document"] == 99
def test_related_document_none_when_no_result_data(
self,
@@ -343,7 +360,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["related_document"] is None
+ assert response.data["results"][0]["related_document"] is None
def test_duplicate_documents_from_result_data(self, v9_client: APIClient) -> None:
"""duplicate_documents includes duplicate_of from result_data in v9."""
@@ -356,7 +373,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- dupes = response.data[0]["duplicate_documents"]
+ dupes = response.data["results"][0]["duplicate_documents"]
assert len(dupes) == 1
assert dupes[0]["id"] == doc.pk
assert dupes[0]["title"] == doc.title
@@ -372,7 +389,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["duplicate_documents"] == []
+ assert response.data["results"][0]["duplicate_documents"] == []
def test_status_remapped_to_uppercase(self, v9_client: APIClient) -> None:
"""v9 status values are uppercase Celery state strings."""
@@ -383,7 +400,7 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- statuses = {t["status"] for t in response.data}
+ statuses = {t["status"] for t in response.data["results"]}
assert statuses == {"SUCCESS", "PENDING", "FAILURE"}
def test_filter_by_task_name_maps_old_value(self, v9_client: APIClient) -> None:
@@ -394,8 +411,8 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT, {"task_name": "check_sanity"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["task_name"] == "check_sanity"
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["task_name"] == "check_sanity"
def test_v9_non_staff_sees_own_and_unowned_tasks(
self,
@@ -418,7 +435,7 @@ class TestGetTasksV9:
response = client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
+ assert response.data["count"] == 2
def test_filter_by_task_name_maps_to_task_type(self, v9_client: APIClient) -> None:
"""?task_name=consume_file filter maps to the task_type field for v9 compatibility."""
@@ -428,8 +445,8 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT, {"task_name": "consume_file"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["task_name"] == "consume_file"
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["task_name"] == "consume_file"
def test_filter_by_type_scheduled_task(self, v9_client: APIClient) -> None:
"""?type=scheduled_task matches trigger_source=scheduled only."""
@@ -439,8 +456,8 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT, {"type": "scheduled_task"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
- assert response.data[0]["type"] == "scheduled_task"
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["type"] == "scheduled_task"
def test_filter_by_type_auto_task_includes_all_auto_sources(
self,
@@ -457,8 +474,8 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT, {"type": "auto_task"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 3
- assert all(t["type"] == "auto_task" for t in response.data)
+ assert response.data["count"] == 3
+ assert all(t["type"] == "auto_task" for t in response.data["results"])
def test_filter_by_type_manual_task_includes_all_manual_sources(
self,
@@ -475,8 +492,8 @@ class TestGetTasksV9:
response = v9_client.get(ENDPOINT, {"type": "manual_task"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 3
- assert all(t["type"] == "manual_task" for t in response.data)
+ assert response.data["count"] == 3
+ assert all(t["type"] == "manual_task" for t in response.data["results"])
@pytest.mark.django_db()
@@ -510,7 +527,7 @@ class TestAcknowledge:
response = admin_client.get(ENDPOINT, {"acknowledged": "false"})
assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 0
+ assert response.data["count"] == 0
def test_requires_change_permission(self, user_client: APIClient) -> None:
"""Regular users without change_paperlesstask permission receive 403."""
@@ -828,7 +845,7 @@ class TestDuplicateDocumentsPermissions:
response = user_v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- dupes = response.data[0]["duplicate_documents"]
+ dupes = response.data["results"][0]["duplicate_documents"]
assert len(dupes) == 1
assert dupes[0]["id"] == doc.pk
@@ -848,7 +865,7 @@ class TestDuplicateDocumentsPermissions:
response = user_v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert len(response.data[0]["duplicate_documents"]) == 1
+ assert len(response.data["results"][0]["duplicate_documents"]) == 1
def test_other_users_duplicate_document_is_hidden(
self,
@@ -867,7 +884,7 @@ class TestDuplicateDocumentsPermissions:
response = user_v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- assert response.data[0]["duplicate_documents"] == []
+ assert response.data["results"][0]["duplicate_documents"] == []
def test_explicit_permission_grants_visibility(
self,
@@ -887,6 +904,6 @@ class TestDuplicateDocumentsPermissions:
response = user_v9_client.get(ENDPOINT)
assert response.status_code == status.HTTP_200_OK
- dupes = response.data[0]["duplicate_documents"]
+ dupes = response.data["results"][0]["duplicate_documents"]
assert len(dupes) == 1
assert dupes[0]["id"] == doc.pk
diff --git a/src/documents/views.py b/src/documents/views.py
index 74c7733cf..54c8f1ae6 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -69,6 +69,7 @@ from django.views.decorators.http import condition
from django.views.decorators.http import last_modified
from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
+from drf_spectacular.openapi import AutoSchema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
@@ -3799,6 +3800,15 @@ class RemoteVersionView(GenericAPIView[Any]):
)
+class _TasksViewSetSchema(AutoSchema):
+ _UNPAGINATED_ACTIONS = frozenset({"summary", "active"})
+
+ def _get_paginator(self):
+ if getattr(self.view, "action", None) in self._UNPAGINATED_ACTIONS:
+ return None
+ return super()._get_paginator()
+
+
@extend_schema_view(
list=extend_schema(
parameters=[
@@ -3857,7 +3867,9 @@ class RemoteVersionView(GenericAPIView[Any]):
),
)
class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
+ schema = _TasksViewSetSchema()
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
+ pagination_class = StandardPagination
filter_backends = (
DjangoFilterBackend,
OrderingFilter,