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,