mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-28 03:40:43 -04:00
Chore: Paginate the task listing (#12633)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
parent
dbce393604
commit
d6e45093e8
@ -98,6 +98,18 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||
</button>
|
||||
}
|
||||
|
||||
<ngb-pagination
|
||||
class="ms-md-3 mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="totalTasks"
|
||||
[page]="page"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
size="sm"
|
||||
aria-label="Tasks pagination"
|
||||
(pageChange)="setPage($event)">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
|
||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||
|
||||
@ -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<PaperlessTask> = {
|
||||
count: tasks.length,
|
||||
results: tasks,
|
||||
}
|
||||
|
||||
describe('TasksComponent', () => {
|
||||
let component: TasksComponent
|
||||
let fixture: ComponentFixture<TasksComponent>
|
||||
@ -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())
|
||||
})
|
||||
|
||||
@ -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<number> = 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
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<unknown>) =>
|
||||
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<unknown>) =>
|
||||
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<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', () => {
|
||||
@ -124,10 +138,13 @@ describe('TasksService', () => {
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
(req: HttpRequest<unknown>) =>
|
||||
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<unknown>) =>
|
||||
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(
|
||||
|
||||
@ -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<PaperlessTask[]>(
|
||||
`${this.baseUrl}${this.endpoint}/?acknowledged=false`
|
||||
)
|
||||
.get<Results<PaperlessTask>>(`${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<string, string | number | boolean>
|
||||
): Observable<Results<PaperlessTask>> {
|
||||
return this.http.get<Results<PaperlessTask>>(
|
||||
`${this.baseUrl}${this.endpoint}/`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...extraParams,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public dismissTasks(task_ids: Set<number>): Observable<any> {
|
||||
return this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user