mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-19 22:38:13 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31caf01ae5 | ||
|
|
e756f5985f | ||
|
|
ce1dee7549 | ||
|
|
fd187cd09a | ||
|
|
814b208280 | ||
|
|
11c32d352e | ||
|
|
c21deaa452 | ||
|
|
c47766121d | ||
|
|
979bcd3896 | ||
|
|
d450f03d5f | ||
|
|
730c2d8688 | ||
|
|
b1738c85d7 | ||
|
|
a8b835fcd0 | ||
|
|
41498113d3 | ||
|
|
309a4b004d | ||
|
|
961f7fd672 | ||
|
|
de94657e17 | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 | ||
|
|
15e9a7a017 | ||
|
|
04a927c25f | ||
|
|
30a24ee02e | ||
|
|
1943c99b27 | ||
|
|
8b26ecafb6 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 | ||
|
|
34a6c718fa | ||
|
|
14aaa42777 | ||
|
|
c86c822bc7 | ||
|
|
b23b118a49 |
No files matched your search
@@ -76,11 +76,12 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reaching here with the honeypot means AI usage was not disclosed in the form, so that
|
|
||||||
// message can name the cause.
|
|
||||||
// A missing template label only tells us the issue did not come from the form, which
|
// A missing template label only tells us the issue did not come from the form, which
|
||||||
// is reason enough to close it, but not proof of how it was written.
|
// is reason enough to close it, but not proof of how it was written. It takes priority:
|
||||||
const body = honeypot
|
// a hand-built body can carry the honeypot *and* a disclosure, and the form message is
|
||||||
|
// the only one that is accurate for both. Reaching the honeypot message means the issue
|
||||||
|
// came from the form without disclosing AI usage, so that message can name the cause.
|
||||||
|
const body = honeypot && !bypassedTemplate
|
||||||
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
||||||
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
||||||
'disclose this.\n\n' +
|
'disclose this.\n\n' +
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ src/documents/bulk_edit.py:0: error: Incompatible types in assignment (expressio
|
|||||||
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
||||||
|
src/documents/bulk_edit.py:0: error: Missing named argument "p" for "remove" of "PageList" [call-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
||||||
|
|||||||
@@ -91,6 +91,13 @@
|
|||||||
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
||||||
"severity": "error"
|
"severity": "error"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column": 33,
|
||||||
|
"path": "src/documents/bulk_edit.py",
|
||||||
|
"name": "missing-argument",
|
||||||
|
"concise_description": "Missing argument `p` in function `pikepdf._core.PageList.remove`",
|
||||||
|
"severity": "error"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column": 25,
|
"column": 25,
|
||||||
"path": "src/documents/caching.py",
|
"path": "src/documents/caching.py",
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.3.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
||||||
|
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
||||||
|
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>5 changes</summary>
|
||||||
|
|
||||||
|
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
||||||
|
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
||||||
|
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
||||||
|
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.3.1
|
## paperless-ngx 3.3.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.1"
|
version = "3.3.3"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock>=3.32,<4.1",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ module.exports = {
|
|||||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||||
useESM: true,
|
useESM: true,
|
||||||
|
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
|
||||||
|
isolatedModules: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+178
-167
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.3.1",
|
"version": "3.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -102,25 +102,34 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ngb-pagination
|
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
|
||||||
[pageSize]="pageSize"
|
<ngb-pagination
|
||||||
[collectionSize]="totalTasks()"
|
[pageSize]="pageSize"
|
||||||
[page]="page"
|
[collectionSize]="totalTasks()"
|
||||||
[maxSize]="5"
|
[page]="page"
|
||||||
[rotate]="true"
|
[maxSize]="5"
|
||||||
size="sm"
|
[rotate]="true"
|
||||||
aria-label="Tasks pagination"
|
size="sm"
|
||||||
(pageChange)="setPage($event)">
|
aria-label="Tasks pagination"
|
||||||
</ngb-pagination>
|
(pageChange)="setPage($event)">
|
||||||
|
</ngb-pagination>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||||
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
<div class="section-header mb-2">
|
||||||
<div>
|
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
@let count = sectionCount(section);
|
||||||
<div class="small text-muted">
|
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
|
||||||
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
<div class="small text-muted">
|
||||||
</div>
|
@if (hasMore) {
|
||||||
|
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
|
||||||
|
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
|
||||||
|
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,10 +178,9 @@
|
|||||||
<div>{{ taskDisplayName(task) }}</div>
|
<div>{{ taskDisplayName(task) }}</div>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
@if (taskShowsSeparateTypeLabel(task)) {
|
@if (taskShowsSeparateTypeLabel(task)) {
|
||||||
<span>{{ task.task_type_display }}</span>
|
<span class="me-2">{{ task.task_type_display }}</span>
|
||||||
<span class="mx-1">•</span>
|
|
||||||
}
|
}
|
||||||
<span>{{ task.trigger_source_display }}</span>
|
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
||||||
|
|||||||
@@ -72,3 +72,7 @@ pre {
|
|||||||
tbody tr:nth-last-child(2) td {
|
tbody tr:nth-last-child(2) td {
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header .show-all {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
@@ -223,16 +223,6 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
.flush(paginatedTasks)
|
.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)
|
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req) =>
|
(req) =>
|
||||||
@@ -241,8 +231,30 @@ describe('TasksComponent', () => {
|
|||||||
!req.params.has('status')
|
!req.params.has('status')
|
||||||
)
|
)
|
||||||
.flush(sectionCountResponse)
|
.flush(sectionCountResponse)
|
||||||
|
|
||||||
|
flushTaskLists(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// answers task list requests with the fixture tasks matching their status filter
|
||||||
|
function flushTaskLists(pageSize: number, count?: number) {
|
||||||
|
httpTestingController
|
||||||
|
.match(
|
||||||
|
(req) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('page_size') === `${pageSize}`
|
||||||
|
)
|
||||||
|
.forEach((req) => {
|
||||||
|
const statuses = req.request.params.getAll('status')
|
||||||
|
const results = tasks.filter((task) => statuses.includes(task.status))
|
||||||
|
req.flush({ count: count ?? results.length, results })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSection(section: TaskSection, count: number) {
|
||||||
|
component.setSection(section)
|
||||||
|
flushTaskLists(25, count)
|
||||||
|
}
|
||||||
|
|
||||||
it('should display task sections with counts', () => {
|
it('should display task sections with counts', () => {
|
||||||
expect(component.selectedSection).toBe(TaskSection.All)
|
expect(component.selectedSection).toBe(TaskSection.All)
|
||||||
expect(component.selectedTaskType).toBeNull()
|
expect(component.selectedTaskType).toBeNull()
|
||||||
@@ -338,6 +350,7 @@ describe('TasksComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render pagination controls next to the task filter', () => {
|
it('should render pagination controls next to the task filter', () => {
|
||||||
|
selectSection(TaskSection.Completed, 30)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||||
@@ -348,6 +361,51 @@ describe('TasksComponent', () => {
|
|||||||
expect(pagination).not.toBeNull()
|
expect(pagination).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only paginate a section with more than one page of tasks', () => {
|
||||||
|
const pagination = () =>
|
||||||
|
fixture.debugElement.query(By.css('ngb-pagination'))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(pagination()).toBeNull()
|
||||||
|
|
||||||
|
selectSection(TaskSection.Completed, 2)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(pagination()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load the all tasks overview one section at a time', () => {
|
||||||
|
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||||
|
2
|
||||||
|
)
|
||||||
|
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
|
||||||
|
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
|
||||||
|
expect(component.totalTasks()).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show section totals and link truncated sections to their full list', () => {
|
||||||
|
component.sectionCounts.update((counts) => ({
|
||||||
|
...counts,
|
||||||
|
[TaskSection.Completed]: 27,
|
||||||
|
}))
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const header = (label: string) =>
|
||||||
|
fixture.debugElement
|
||||||
|
.queryAll(By.css('.section-header'))
|
||||||
|
.find((h) => h.nativeElement.textContent.includes(label))
|
||||||
|
|
||||||
|
expect(header('Needs attention').nativeElement.textContent).toContain(
|
||||||
|
'2 tasks'
|
||||||
|
)
|
||||||
|
expect(header('Needs attention').query(By.css('button'))).toBeNull()
|
||||||
|
|
||||||
|
expect(header('Recently completed').nativeElement.textContent).toContain(
|
||||||
|
'2 of 27 tasks'
|
||||||
|
)
|
||||||
|
header('Recently completed').query(By.css('button')).nativeElement.click()
|
||||||
|
expect(component.selectedSection).toBe(TaskSection.Completed)
|
||||||
|
})
|
||||||
|
|
||||||
it('should apply the selected section to the server-side task query', () => {
|
it('should apply the selected section to the server-side task query', () => {
|
||||||
component.setSection(TaskSection.NeedsAttention)
|
component.setSection(TaskSection.NeedsAttention)
|
||||||
|
|
||||||
@@ -368,55 +426,56 @@ describe('TasksComponent', () => {
|
|||||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||||
|
|
||||||
httpTestingController
|
expect(
|
||||||
.expectOne(
|
httpTestingController.match(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '25' &&
|
request.params.get('page_size') === '10' &&
|
||||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
||||||
)
|
)
|
||||||
.flush({ count: 1, results: [tasks[6]] })
|
).toHaveLength(3)
|
||||||
|
|
||||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||||
|
|
||||||
httpTestingController
|
expect(
|
||||||
.expectOne(
|
httpTestingController.match(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '25' &&
|
request.params.get('page_size') === '10' &&
|
||||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
||||||
request.params.get('trigger_source') ===
|
request.params.get('trigger_source') ===
|
||||||
PaperlessTaskTriggerSource.System
|
PaperlessTaskTriggerSource.System
|
||||||
)
|
)
|
||||||
.flush({ count: 1, results: [tasks[6]] })
|
).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply text filters to the server-side task query', () => {
|
it('should apply text filters to the server-side task query', () => {
|
||||||
component.filterText = 'invoice'
|
component.filterText = 'invoice'
|
||||||
jest.advanceTimersByTime(150)
|
jest.advanceTimersByTime(150)
|
||||||
|
|
||||||
httpTestingController
|
expect(
|
||||||
.expectOne(
|
httpTestingController.match(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '25' &&
|
request.params.get('page_size') === '10' &&
|
||||||
request.params.get('name') === 'invoice'
|
request.params.get('name') === 'invoice'
|
||||||
)
|
)
|
||||||
.flush({ count: 1, results: [tasks[0]] })
|
).toHaveLength(3)
|
||||||
|
|
||||||
component.setFilterTarget(TaskFilterTargetID.Result)
|
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||||
|
|
||||||
httpTestingController
|
expect(
|
||||||
.expectOne(
|
httpTestingController.match(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '25' &&
|
request.params.get('page_size') === '10' &&
|
||||||
request.params.get('result') === 'invoice'
|
request.params.get('result') === 'invoice'
|
||||||
)
|
)
|
||||||
.flush({ count: 0, results: [] })
|
).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should load a different task page when pagination changes', () => {
|
it('should load a different task page when pagination changes', () => {
|
||||||
|
selectSection(TaskSection.Completed, 30)
|
||||||
component.setPage(2)
|
component.setPage(2)
|
||||||
|
|
||||||
const pageTwoTasks = {
|
const pageTwoTasks = {
|
||||||
@@ -440,6 +499,7 @@ describe('TasksComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not replace section counts with current-page counts', () => {
|
it('should not replace section counts with current-page counts', () => {
|
||||||
|
selectSection(TaskSection.Completed, 30)
|
||||||
component.setPage(2)
|
component.setPage(2)
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
@@ -455,11 +515,21 @@ describe('TasksComponent', () => {
|
|||||||
results: [tasks[0]],
|
results: [tasks[0]],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(component.sectionCount(TaskSection.All)).toBe(7)
|
||||||
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
||||||
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
||||||
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stop loading when the overview fails to load', () => {
|
||||||
|
component.setSection(TaskSection.All)
|
||||||
|
httpTestingController
|
||||||
|
.match((req) => req.params.get('page_size') === '10')[0]
|
||||||
|
.flush('', { status: 500, statusText: 'Error' })
|
||||||
|
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('should expose stable task type options and disable empty ones', () => {
|
it('should expose stable task type options and disable empty ones', () => {
|
||||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||||
PaperlessTaskType.TrainClassifier
|
PaperlessTaskType.TrainClassifier
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
first,
|
first,
|
||||||
|
forkJoin,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
Subject,
|
Subject,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
timer,
|
timer,
|
||||||
@@ -25,6 +28,7 @@ import {
|
|||||||
PaperlessTaskTriggerSource,
|
PaperlessTaskTriggerSource,
|
||||||
PaperlessTaskType,
|
PaperlessTaskType,
|
||||||
} from 'src/app/data/paperless-task'
|
} from 'src/app/data/paperless-task'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
@@ -169,6 +173,7 @@ export class TasksComponent
|
|||||||
public expandedTask: number
|
public expandedTask: number
|
||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
public readonly pageSize = 25
|
public readonly pageSize = 25
|
||||||
|
public readonly overviewSize = 10
|
||||||
public page: number = 1
|
public page: number = 1
|
||||||
readonly totalTasks = signal(0)
|
readonly totalTasks = signal(0)
|
||||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
||||||
@@ -484,13 +489,6 @@ export class TasksComponent
|
|||||||
return this.sectionCounts()[section]
|
return this.sectionCounts()[section]
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSectionCount(section: TaskSection, count: number) {
|
|
||||||
this.sectionCounts.update((counts) => ({
|
|
||||||
...counts,
|
|
||||||
[section]: count,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionShowsResults(section: TaskSection): boolean {
|
sectionShowsResults(section: TaskSection): boolean {
|
||||||
return section !== TaskSection.InProgress
|
return section !== TaskSection.InProgress
|
||||||
}
|
}
|
||||||
@@ -737,21 +735,12 @@ export class TasksComponent
|
|||||||
this.reloadSectionCounts()
|
this.reloadSectionCounts()
|
||||||
|
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.tasksService
|
this.listTasks()
|
||||||
.list(
|
|
||||||
this.page,
|
|
||||||
this.pageSize,
|
|
||||||
this.getParamsForSection(this.selectedSection)
|
|
||||||
)
|
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.pagedTasks.set(result.results)
|
this.pagedTasks.set(result.results)
|
||||||
this.totalTasks.set(result.count)
|
this.totalTasks.set(result.count)
|
||||||
this.setSectionCount(TaskSection.All, result.count)
|
|
||||||
if (this.selectedSection !== TaskSection.All) {
|
|
||||||
this.setSectionCount(this.selectedSection, result.count)
|
|
||||||
}
|
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
if (
|
if (
|
||||||
this.page > 1 &&
|
this.page > 1 &&
|
||||||
@@ -767,4 +756,30 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private listTasks(): Observable<Results<PaperlessTask>> {
|
||||||
|
if (this.selectedSection !== TaskSection.All) {
|
||||||
|
return this.tasksService.list(
|
||||||
|
this.page,
|
||||||
|
this.pageSize,
|
||||||
|
this.getParamsForSection(this.selectedSection)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All is an overview of each section to avoid splitting sections across pages
|
||||||
|
return forkJoin(
|
||||||
|
this.sections.map((section) =>
|
||||||
|
this.tasksService.list(
|
||||||
|
1,
|
||||||
|
this.overviewSize,
|
||||||
|
this.getParamsForSection(section)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).pipe(
|
||||||
|
map((results) => ({
|
||||||
|
count: results.reduce((sum, result) => sum + result.count, 0),
|
||||||
|
results: results.flatMap((result) => result.results),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,31 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should logout current user under base href if password changed', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const base = document.createElement('base')
|
||||||
|
base.href = '/paperless/'
|
||||||
|
document.head.appendChild(base)
|
||||||
|
completeSetup()
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||||
|
component.editUser(users[0])
|
||||||
|
const navSpy = jest
|
||||||
|
.spyOn(navUtils, 'setLocationHref')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
|
editDialog.passwordIsSet = true
|
||||||
|
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
||||||
|
editDialog.succeeded.emit(users[0])
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest.advanceTimersByTime(2600)
|
||||||
|
expect(navSpy).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
||||||
|
)
|
||||||
|
base.remove()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support edit / create group, show error if needed', () => {
|
it('should support edit / create group, show error if needed', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Password has been changed, you will be logged out momentarily.`
|
$localize`Password has been changed, you will be logged out momentarily.`
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const basePath = new URL(document.baseURI).pathname
|
||||||
setLocationHref(
|
setLocationHref(
|
||||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
|
min-width: 0;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
@@ -499,6 +500,11 @@ main {
|
|||||||
|
|
||||||
// true center the search with equal flex widths
|
// true center the search with equal flex widths
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.search-container {
|
||||||
|
// Prevent input padding and the conditional search button from resizing
|
||||||
|
flex-basis: 55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-brand-container,
|
.navbar-brand-container,
|
||||||
.navbar > ul {
|
.navbar > ul {
|
||||||
display: flex; // so the brand link stays as wide as its contents
|
display: flex; // so the brand link stays as wide as its contents
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ form {
|
|||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown.show {
|
||||||
|
// Hide the search icon when the dropdown is shown
|
||||||
|
i-bs[name="search"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
&:has(button:focus) {
|
&:has(button:focus) {
|
||||||
background-color: var(--pngx-bg-darker);
|
background-color: var(--pngx-bg-darker);
|
||||||
|
|||||||
+32
@@ -218,6 +218,38 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should logout under base href on save if password changed', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const base = document.createElement('base')
|
||||||
|
base.href = '/paperless/'
|
||||||
|
document.head.appendChild(base)
|
||||||
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
|
component.ngOnInit()
|
||||||
|
component['newPassword'] = 'new*pass'
|
||||||
|
component.form.get('password').patchValue('new*pass')
|
||||||
|
component.form.get('password_confirm').patchValue('new*pass')
|
||||||
|
|
||||||
|
const updateSpy = jest.spyOn(profileService, 'update')
|
||||||
|
updateSpy.mockReturnValue(of(null))
|
||||||
|
const navSpy = jest
|
||||||
|
.spyOn(navUtils, 'setLocationHref')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
component.save()
|
||||||
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
|
jest.advanceTimersByTime(2600)
|
||||||
|
expect(navSpy).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
||||||
|
)
|
||||||
|
base.remove()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support auth token copy', () => {
|
it('should support auth token copy', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
|
|||||||
+2
-1
@@ -210,8 +210,9 @@ export class ProfileEditDialogComponent
|
|||||||
$localize`Password has been changed, you will be logged out momentarily.`
|
$localize`Password has been changed, you will be logged out momentarily.`
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const basePath = new URL(document.baseURI).pathname
|
||||||
setLocationHref(
|
setLocationHref(
|
||||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-35
@@ -1,6 +1,6 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else if (noSuggestions) {
|
} @else if (noSuggestions) {
|
||||||
@@ -18,46 +18,44 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
}
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
}
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
}
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
}
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
}
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
@if (fieldSuggestions > 0) {
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
@if (fieldSuggestions > 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
@if (aiEnabled()) {
|
||||||
|
|||||||
+31
@@ -251,6 +251,37 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should open ML date suggestions with AI disabled', () => {
|
||||||
|
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
|
||||||
|
fixture.componentRef.setInput('aiEnabled', false)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
dates: ['2026-01-04', '2026-02-01'],
|
||||||
|
})
|
||||||
|
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const button = fixture.nativeElement.querySelector('button')
|
||||||
|
expect(button.disabled).toBeFalsy()
|
||||||
|
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
|
||||||
|
|
||||||
|
button.click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'1 suggestion available below'
|
||||||
|
)
|
||||||
|
expect(getSuggestions).not.toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
button.click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.dropdown.isOpen()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
|||||||
+1
-5
@@ -53,11 +53,7 @@ export class SuggestionsDropdownComponent {
|
|||||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||||
|
|
||||||
public clickSuggest(): void {
|
public clickSuggest(): void {
|
||||||
if (
|
if (this.disabled() || this.loading()) {
|
||||||
this.disabled() ||
|
|
||||||
this.loading() ||
|
|
||||||
(this.suggestions() && !this.aiEnabled())
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -25,6 +25,8 @@
|
|||||||
@if (displayFields().includes(field)) {
|
@if (displayFields().includes(field)) {
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
|
class="text-truncate"
|
||||||
|
[title]="getColumnTitle(field)"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'd-none d-md-table-cell': i > 1,
|
'd-none d-md-table-cell': i > 1,
|
||||||
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
||||||
|
|||||||
+46
-10
@@ -484,6 +484,22 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.textFilter).toBeNull()
|
expect(component.textFilter).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ingest title filter rules alongside a fulltext query with date created', () => {
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
|
value: 'created:[-1 week to now]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
|
||||||
|
expect(component.textFilter).toEqual('foo')
|
||||||
|
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest text filter rules for fulltext query that include date added', () => {
|
it('should ingest text filter rules for fulltext query that include date added', () => {
|
||||||
expect(component.dateAddedRelativeDate).toBeNull()
|
expect(component.dateAddedRelativeDate).toBeNull()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1845,7 +1861,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should carry over text filtering on date created with relative date', () => {
|
it('should keep text filtering separate from date created relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1855,14 +1871,18 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TEXT,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'foo,created:[-1 week to now]',
|
value: 'created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should carry over text filtering once with created and added relative dates', () => {
|
it('should keep text filtering separate from created and added relative dates', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1873,14 +1893,18 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TEXT,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
value: 'created:[-1 week to now],added:[-1 month to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
it('should keep legacy title filters as a title search when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE,
|
rule_type: FILTER_TITLE,
|
||||||
@@ -1895,14 +1919,18 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'foo,created:[-1 week to now]',
|
value: 'created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should convert simple title filters into full text query when adding a created relative date', () => {
|
it('should keep simple title filters as a title search when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1917,9 +1945,13 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'foo,created:[-1 week to now]',
|
value: 'created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -2005,7 +2037,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should carry over text filtering on date added with relative date', () => {
|
it('should keep text filtering separate from date added relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -2015,9 +2047,13 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_SIMPLE_TEXT,
|
||||||
|
value: 'foo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'foo,added:[-1 week to now]',
|
value: 'added:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1083,23 +1083,6 @@ export class FilterEditorComponent
|
|||||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||||
)
|
)
|
||||||
|
|
||||||
// if had a title / content search and added a relative date we need to carry it over...
|
|
||||||
if (
|
|
||||||
!existingRule &&
|
|
||||||
this._textFilter?.length > 0 &&
|
|
||||||
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
|
|
||||||
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
|
|
||||||
) {
|
|
||||||
existingRule = filterRules.find(
|
|
||||||
(fr) =>
|
|
||||||
fr.rule_type == FILTER_TITLE_CONTENT ||
|
|
||||||
fr.rule_type == FILTER_SIMPLE_TEXT ||
|
|
||||||
fr.rule_type == FILTER_TITLE ||
|
|
||||||
fr.rule_type == FILTER_SIMPLE_TITLE
|
|
||||||
)
|
|
||||||
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
|
||||||
}
|
|
||||||
|
|
||||||
let queryArgs = existingRule?.value.split(',') ?? []
|
let queryArgs = existingRule?.value.split(',') ?? []
|
||||||
if (this.dateCreatedRelativeDate !== null) {
|
if (this.dateCreatedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
|
|||||||
+5
-3
@@ -11,7 +11,7 @@
|
|||||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||||
<label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
|
<label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm w-auto me-3">
|
<div class="input-group input-group-sm w-auto">
|
||||||
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||||
<option [ngValue]="25">25</option>
|
<option [ngValue]="25">25</option>
|
||||||
<option [ngValue]="50">50</option>
|
<option [ngValue]="50">50</option>
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||||
</div>
|
</div>
|
||||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
@if (collectionSize() > pageSize) {
|
||||||
|
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" class="ms-3" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (collectionSize() > 20) {
|
@if (collectionSize() > pageSize) {
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-9
@@ -93,15 +93,17 @@
|
|||||||
[disabled]="selectedMailIds().size === 0"
|
[disabled]="selectedMailIds().size === 0"
|
||||||
(confirm)="deleteSelected()">
|
(confirm)="deleteSelected()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
<div class="ms-auto">
|
@if (processedMails().length > 50) {
|
||||||
<ngb-pagination
|
<div class="ms-auto">
|
||||||
[collectionSize]="processedMails().length"
|
<ngb-pagination
|
||||||
[(page)]="page"
|
[collectionSize]="processedMails().length"
|
||||||
[pageSize]="50"
|
[(page)]="page"
|
||||||
[maxSize]="5"
|
[pageSize]="50"
|
||||||
(pageChange)="loadProcessedMails()">
|
[maxSize]="5"
|
||||||
</ngb-pagination>
|
(pageChange)="loadProcessedMails()">
|
||||||
</div>
|
</ngb-pagination>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.3.1',
|
version: '3.3.3',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@ import {
|
|||||||
bell,
|
bell,
|
||||||
bodyText,
|
bodyText,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
@@ -293,6 +294,7 @@ const icons = {
|
|||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
|
|||||||
+219
-184
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -24,7 +23,6 @@ from django.db.models import When
|
|||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from documents import pdf_ops
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
@@ -125,11 +123,6 @@ def _resolve_root_and_source_doc(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scratch_path(name: str) -> Path:
|
|
||||||
"""A path inside a fresh directory under SCRATCH_DIR."""
|
|
||||||
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
|
||||||
|
|
||||||
|
|
||||||
def set_correspondent(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
@@ -506,6 +499,8 @@ def rotate(
|
|||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
for pair in docs_by_root_id.values():
|
for pair in docs_by_root_id.values():
|
||||||
if pair.source_doc.mime_type != "application/pdf":
|
if pair.source_doc.mime_type != "application/pdf":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -518,7 +513,11 @@ def rotate(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_rotated.pdf"
|
/ f"{pair.root_doc.id}_rotated.pdf"
|
||||||
)
|
)
|
||||||
pdf_ops.rotate_pdf(pair.source_doc.source_path, filepath, degrees)
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
page.rotate(degrees, relative=True)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
# Preserve metadata/permissions via overrides; mark as new version
|
# Preserve metadata/permissions via overrides; mark as new version
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -561,45 +560,48 @@ def merge(
|
|||||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||||
docs_by_id = {doc.id: doc for doc in qs}
|
docs_by_id = {doc.id: doc for doc in qs}
|
||||||
affected_docs: list[int] = []
|
affected_docs: list[int] = []
|
||||||
handoff_asn: int | None = None
|
import pikepdf
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
# use doc_ids to preserve order
|
|
||||||
for doc_id in doc_ids:
|
|
||||||
doc = docs_by_id.get(doc_id)
|
|
||||||
if doc is None:
|
|
||||||
continue
|
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
|
||||||
try:
|
|
||||||
# archive_path is None when there is no archive version
|
|
||||||
archive_path = (
|
|
||||||
pair.source_doc.archive_path
|
|
||||||
if archive_fallback
|
|
||||||
and pair.source_doc.mime_type != "application/pdf"
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
merger.add(
|
|
||||||
archive_path
|
|
||||||
if archive_path is not None
|
|
||||||
else pair.source_doc.source_path,
|
|
||||||
)
|
|
||||||
affected_docs.append(doc.id)
|
|
||||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
|
||||||
handoff_asn = doc.archive_serial_number
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
|
||||||
)
|
|
||||||
if len(affected_docs) == 0:
|
|
||||||
logger.warning("No documents were merged")
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
filepath = (
|
merged_pdf = pikepdf.new()
|
||||||
Path(
|
version: str = merged_pdf.pdf_version
|
||||||
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
handoff_asn: int | None = None
|
||||||
|
# use doc_ids to preserve order
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
doc = docs_by_id.get(doc_id)
|
||||||
|
if doc is None:
|
||||||
|
continue
|
||||||
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
try:
|
||||||
|
doc_path = (
|
||||||
|
pair.source_doc.archive_path
|
||||||
|
if archive_fallback
|
||||||
|
and pair.source_doc.mime_type != "application/pdf"
|
||||||
|
and pair.source_doc.has_archive_version
|
||||||
|
else pair.source_doc.source_path
|
||||||
)
|
)
|
||||||
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
with pikepdf.open(str(doc_path)) as pdf:
|
||||||
|
version = max(version, pdf.pdf_version)
|
||||||
|
merged_pdf.pages.extend(pdf.pages)
|
||||||
|
affected_docs.append(doc.id)
|
||||||
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
|
handoff_asn = doc.archive_serial_number
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
|
)
|
||||||
|
if len(affected_docs) == 0:
|
||||||
|
logger.warning("No documents were merged")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
filepath = (
|
||||||
|
Path(
|
||||||
|
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
||||||
)
|
)
|
||||||
merger.save(filepath)
|
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
||||||
|
)
|
||||||
|
merged_pdf.remove_unreferenced_resources()
|
||||||
|
merged_pdf.save(filepath, min_version=version)
|
||||||
|
merged_pdf.close()
|
||||||
|
|
||||||
if metadata_document_id:
|
if metadata_document_id:
|
||||||
metadata_document = qs.get(id=metadata_document_id)
|
metadata_document = qs.get(id=metadata_document_id)
|
||||||
@@ -775,60 +777,64 @@ def split(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
outputs = [
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
(
|
for idx, split_doc in enumerate(pages):
|
||||||
[pdf_ops.PageSpec(page) for page in split_doc],
|
dst: pikepdf.Pdf = pikepdf.new()
|
||||||
partial(_scratch_path, f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"),
|
for page in split_doc:
|
||||||
)
|
dst.pages.append(pdf.pages[page - 1])
|
||||||
for split_doc in pages
|
filepath: Path = (
|
||||||
]
|
Path(
|
||||||
filepaths = pdf_ops.build_pdfs(pair.source_doc.source_path, outputs)
|
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
||||||
|
)
|
||||||
|
/ f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"
|
||||||
|
)
|
||||||
|
dst.remove_unreferenced_resources()
|
||||||
|
dst.save(filepath)
|
||||||
|
dst.close()
|
||||||
|
|
||||||
for idx, (split_doc, filepath) in enumerate(
|
overrides: DocumentMetadataOverrides = (
|
||||||
zip(pages, filepaths, strict=True),
|
DocumentMetadataOverrides().from_document(doc)
|
||||||
):
|
)
|
||||||
overrides: DocumentMetadataOverrides = (
|
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||||
DocumentMetadataOverrides().from_document(doc)
|
if user is not None:
|
||||||
)
|
overrides.owner_id = user.id
|
||||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
if not delete_originals:
|
||||||
if user is not None:
|
overrides.skip_asn_if_exists = True
|
||||||
overrides.owner_id = user.id
|
logger.info(
|
||||||
if not delete_originals:
|
f"Adding split document with pages {split_doc} to the task queue.",
|
||||||
overrides.skip_asn_if_exists = True
|
)
|
||||||
logger.info(
|
consume_tasks.append(
|
||||||
f"Adding split document with pages {split_doc} to the task queue.",
|
consume_file.s(
|
||||||
)
|
input_doc=ConsumableDocument(
|
||||||
consume_tasks.append(
|
source=DocumentSource.ConsumeFolder,
|
||||||
consume_file.s(
|
original_file=filepath,
|
||||||
input_doc=ConsumableDocument(
|
),
|
||||||
source=DocumentSource.ConsumeFolder,
|
overrides=overrides,
|
||||||
original_file=filepath,
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
),
|
)
|
||||||
overrides=overrides,
|
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
chord(
|
chord(
|
||||||
header=consume_tasks,
|
header=consume_tasks,
|
||||||
body=delete.si([doc.id]),
|
body=delete.si([doc.id]),
|
||||||
).on_error(
|
).on_error(
|
||||||
restore_archive_serial_numbers_task.s(backup),
|
restore_archive_serial_numbers_task.s(backup),
|
||||||
).apply_async()
|
).apply_async()
|
||||||
except Exception:
|
except Exception:
|
||||||
restore_archive_serial_numbers(backup)
|
restore_archive_serial_numbers(backup)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||||
@@ -849,6 +855,8 @@ def delete_pages(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
pages = sorted(pages) # sort pages to avoid index issues
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Produce edited PDF to a temp file and create a new version
|
# Produce edited PDF to a temp file and create a new version
|
||||||
@@ -856,7 +864,13 @@ def delete_pages(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
||||||
)
|
)
|
||||||
pdf_ops.remove_pages(pair.source_doc.source_path, filepath, pages)
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
|
offset = 1 # pages are 1-indexed
|
||||||
|
for page_num in pages:
|
||||||
|
pdf.pages.remove(pdf.pages[page_num - offset])
|
||||||
|
offset += 1 # remove() changes the index of the pages
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
@@ -905,28 +919,47 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
pdf_docs: list[pikepdf.Pdf] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output_count = pdf_ops.validate_page_operations(
|
if not operations:
|
||||||
operations,
|
raise ValueError("Output document index is out of bounds")
|
||||||
single_output=update_document,
|
|
||||||
)
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
page_specs: list[list[pdf_ops.PageSpec]] = [[] for _ in range(output_count)]
|
if update_document and max_idx > 0:
|
||||||
for op in operations:
|
logger.error(
|
||||||
page_specs[op.get("doc", 0)].append(
|
"Update requested but multiple output documents specified",
|
||||||
pdf_ops.PageSpec(op["page"], op.get("rotate", 0)),
|
|
||||||
)
|
)
|
||||||
|
raise ValueError("Multiple output documents specified")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||||
|
for op in operations
|
||||||
|
):
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
|
with pikepdf.open(pair.source_doc.source_path) as src:
|
||||||
|
# prepare output documents
|
||||||
|
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||||
|
|
||||||
|
for op in operations:
|
||||||
|
dst = pdf_docs[op.get("doc", 0)]
|
||||||
|
page = src.pages[op["page"] - 1]
|
||||||
|
dst.pages.append(page)
|
||||||
|
if op.get("rotate"):
|
||||||
|
dst.pages[-1].rotate(op["rotate"], relative=True)
|
||||||
|
|
||||||
if update_document:
|
if update_document:
|
||||||
# Create a new version from the edited PDF rather than replacing in-place
|
# Create a new version from the edited PDF rather than replacing in-place
|
||||||
(filepath,) = pdf_ops.build_pdfs(
|
pdf = pdf_docs[0]
|
||||||
pair.source_doc.source_path,
|
pdf.remove_unreferenced_resources()
|
||||||
[
|
filepath: Path = (
|
||||||
(
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
page_specs[0],
|
/ f"{pair.root_doc.id}_edited.pdf"
|
||||||
partial(_scratch_path, f"{pair.root_doc.id}_edited.pdf"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
pdf.save(filepath)
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if include_metadata
|
if include_metadata
|
||||||
@@ -947,19 +980,6 @@ def edit_pdf(
|
|||||||
headers={"trigger_source": trigger_source},
|
headers={"trigger_source": trigger_source},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
version_filepaths = pdf_ops.build_pdfs(
|
|
||||||
pair.source_doc.source_path,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
specs,
|
|
||||||
partial(
|
|
||||||
_scratch_path,
|
|
||||||
f"{pair.root_doc.id}_edit_{idx}.pdf",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for idx, specs in enumerate(page_specs, start=1)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -971,9 +991,15 @@ def edit_pdf(
|
|||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
if not delete_original:
|
if not delete_original:
|
||||||
overrides.skip_asn_if_exists = True
|
overrides.skip_asn_if_exists = True
|
||||||
if delete_original and output_count == 1:
|
if delete_original and len(pdf_docs) == 1:
|
||||||
overrides.asn = pair.root_doc.archive_serial_number
|
overrides.asn = pair.root_doc.archive_serial_number
|
||||||
for version_filepath in version_filepaths:
|
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||||
|
version_filepath: Path = (
|
||||||
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
|
/ f"{pair.root_doc.id}_edit_{idx}.pdf"
|
||||||
|
)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(version_filepath)
|
||||||
consume_tasks.append(
|
consume_tasks.append(
|
||||||
consume_file.s(
|
consume_file.s(
|
||||||
input_doc=ConsumableDocument(
|
input_doc=ConsumableDocument(
|
||||||
@@ -1023,6 +1049,8 @@ def remove_password(
|
|||||||
"""
|
"""
|
||||||
Remove password protection from PDF documents.
|
Remove password protection from PDF documents.
|
||||||
"""
|
"""
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
for doc_id in doc_ids:
|
for doc_id in doc_ids:
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
@@ -1036,69 +1064,76 @@ def remove_password(
|
|||||||
doc.id,
|
doc.id,
|
||||||
pair.source_doc.source_path,
|
pair.source_doc.source_path,
|
||||||
)
|
)
|
||||||
if not pdf_ops.needs_decrypt(source_path):
|
try:
|
||||||
logger.info(
|
with pikepdf.open(source_path) as pdf:
|
||||||
"Skipping password removal for document %s because the "
|
if not pdf.is_encrypted:
|
||||||
"source PDF is not encrypted",
|
logger.info(
|
||||||
pair.root_doc.id,
|
"Skipping password removal for document %s because the "
|
||||||
)
|
"source PDF is not encrypted",
|
||||||
continue
|
pair.root_doc.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except pikepdf.PasswordError:
|
||||||
|
# Password-protected PDFs need the supplied password below.
|
||||||
|
pass
|
||||||
|
|
||||||
filepath = pdf_ops.decrypt_pdf(
|
with pikepdf.open(source_path, password=password) as pdf:
|
||||||
source_path,
|
filepath: Path = (
|
||||||
partial(_scratch_path, f"{pair.root_doc.id}_unprotected.pdf"),
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
password,
|
/ f"{pair.root_doc.id}_unprotected.pdf"
|
||||||
)
|
)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
if update_document:
|
if update_document:
|
||||||
# Create a new version rather than modifying the root/original in place.
|
# Create a new version rather than modifying the root/original in place.
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if include_metadata
|
if include_metadata
|
||||||
else DocumentMetadataOverrides()
|
else DocumentMetadataOverrides()
|
||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
consume_file.apply_async(
|
consume_file.apply_async(
|
||||||
kwargs={
|
kwargs={
|
||||||
"input_doc": ConsumableDocument(
|
"input_doc": ConsumableDocument(
|
||||||
source=DocumentSource.ConsumeFolder,
|
source=DocumentSource.ConsumeFolder,
|
||||||
original_file=filepath,
|
original_file=filepath,
|
||||||
root_document_id=pair.root_doc.id,
|
root_document_id=pair.root_doc.id,
|
||||||
),
|
),
|
||||||
"overrides": overrides,
|
"overrides": overrides,
|
||||||
},
|
},
|
||||||
headers={"trigger_source": trigger_source},
|
headers={"trigger_source": trigger_source},
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
consume_tasks = []
|
|
||||||
overrides = (
|
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
|
||||||
if include_metadata
|
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
overrides.owner_id = user.id
|
|
||||||
overrides.actor_id = user.id
|
|
||||||
|
|
||||||
consume_tasks.append(
|
|
||||||
consume_file.s(
|
|
||||||
input_doc=ConsumableDocument(
|
|
||||||
source=DocumentSource.ConsumeFolder,
|
|
||||||
original_file=filepath,
|
|
||||||
),
|
|
||||||
overrides=overrides,
|
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_original:
|
|
||||||
chord(
|
|
||||||
header=consume_tasks,
|
|
||||||
body=delete.si([doc.id]),
|
|
||||||
).delay()
|
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
consume_tasks = []
|
||||||
|
overrides = (
|
||||||
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
|
if include_metadata
|
||||||
|
else DocumentMetadataOverrides()
|
||||||
|
)
|
||||||
|
if user is not None:
|
||||||
|
overrides.owner_id = user.id
|
||||||
|
overrides.actor_id = user.id
|
||||||
|
|
||||||
|
consume_tasks.append(
|
||||||
|
consume_file.s(
|
||||||
|
input_doc=ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=filepath,
|
||||||
|
),
|
||||||
|
overrides=overrides,
|
||||||
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_original:
|
||||||
|
chord(
|
||||||
|
header=consume_tasks,
|
||||||
|
body=delete.si([doc.id]),
|
||||||
|
).delay()
|
||||||
|
else:
|
||||||
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
|||||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||||
"""
|
"""
|
||||||
probas = classifier.predict_proba(X)[0]
|
probas = classifier.predict_proba(X)[0]
|
||||||
|
if len(classifier.classes_) == 1:
|
||||||
|
# scikit-learn treats a single-class fit as binary, so predict_proba
|
||||||
|
# returns [1 - p, p] for one class. Column 0 is the real class.
|
||||||
|
probas = probas[:1]
|
||||||
best_idx = int(probas.argmax())
|
best_idx = int(probas.argmax())
|
||||||
best_class = int(classifier.classes_[best_idx])
|
best_class = int(classifier.classes_[best_idx])
|
||||||
|
|
||||||
|
|||||||
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
|
|||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["view_document"],
|
only_with_perms_in=["view_document"],
|
||||||
|
with_group_users=False,
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.change_users = list(
|
overrides.change_users = list(
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["change_document"],
|
only_with_perms_in=["change_document"],
|
||||||
|
with_group_users=False,
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from itertools import islice
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
@@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration
|
|||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
|
||||||
|
# Derived names replace the original's extension with these suffixes
|
||||||
|
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
|
||||||
|
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
|
||||||
|
# Leave room for the longest derived suffix, which replaces the original's extension
|
||||||
|
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
|
||||||
|
len(THUMBNAIL_SUFFIX),
|
||||||
|
len(ARCHIVE_SUFFIX),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def serialize_queryset_batched(
|
def serialize_queryset_batched(
|
||||||
queryset: "QuerySet[Any]",
|
queryset: "QuerySet[Any]",
|
||||||
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
counter=filename_counter,
|
counter=filename_counter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
base_name = document.get_public_filename(counter=filename_counter)
|
base_name = document.get_public_filename(
|
||||||
|
counter=filename_counter,
|
||||||
|
max_bytes=MAX_BASE_NAME_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
# The thumbnail and archive names are derived from the stem, so
|
# The thumbnail and archive names are derived from the stem, so
|
||||||
# uniqueness must hold without the original's extension
|
# uniqueness must hold without the original's extension
|
||||||
@@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||||
|
|
||||||
if not self.no_thumbnail:
|
if not self.no_thumbnail:
|
||||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||||
thumbnail_arc = thumbnail_name.as_posix()
|
thumbnail_arc = thumbnail_name.as_posix()
|
||||||
@@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
thumbnail_arc = None
|
thumbnail_arc = None
|
||||||
|
|
||||||
if not self.no_archive and document.has_archive_version:
|
if not self.no_archive and document.has_archive_version:
|
||||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
archive_name = Path("archive") / archive_name
|
archive_name = Path("archive") / archive_name
|
||||||
archive_arc = archive_name.as_posix()
|
archive_arc = archive_name.as_posix()
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
|||||||
"""Yield records one at a time from a manifest JSON array via ijson."""
|
"""Yield records one at a time from a manifest JSON array via ijson."""
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
yield from ijson.items(f, "item")
|
yield from ijson.items(f, "item", use_float=True)
|
||||||
except ijson.JSONError as e:
|
except ijson.JSONError as e:
|
||||||
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
||||||
|
|
||||||
|
|||||||
+23
-12
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
|
|||||||
|
|
||||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||||
|
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||||
|
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||||
|
|
||||||
correspondent = models.ForeignKey(
|
correspondent = models.ForeignKey(
|
||||||
Correspondent,
|
Correspondent,
|
||||||
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def archive_file(self):
|
def archive_file(self):
|
||||||
return Path(self.archive_path).open("rb")
|
return Path(self.archive_path).open("rb")
|
||||||
|
|
||||||
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
|
def get_public_filename(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
archive: bool = False,
|
||||||
|
counter: int = 0,
|
||||||
|
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Returns a sanitized filename for the document, not including any paths.
|
Returns a sanitized filename for the document, not including any paths.
|
||||||
|
|
||||||
|
The name fits in max_bytes (UTF-8). A long title is shortened before
|
||||||
|
the counter and extension are appended, because sanitize_filename
|
||||||
|
truncates from the end. The tail is cut only when max_bytes is
|
||||||
|
smaller than the tail itself.
|
||||||
"""
|
"""
|
||||||
# Root owns metadata for all versions
|
# Root owns metadata for all versions
|
||||||
context_document = (
|
context_document = (
|
||||||
self.root_document if self.root_document_id is not None else self
|
self.root_document if self.root_document_id is not None else self
|
||||||
)
|
)
|
||||||
result = str(context_document)
|
|
||||||
|
|
||||||
if counter:
|
extension = ".pdf" if archive else self.file_type
|
||||||
result += f"_{counter:02}"
|
tail = f"_{counter:02}{extension}" if counter else extension
|
||||||
|
|
||||||
if suffix:
|
head_bytes = max(0, max_bytes - len(tail.encode()))
|
||||||
result += suffix
|
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||||
|
|
||||||
if archive:
|
return pathvalidate.sanitize_filename(
|
||||||
result += ".pdf"
|
head + tail,
|
||||||
else:
|
replacement_text="-",
|
||||||
result += self.file_type
|
max_len=max_bytes,
|
||||||
|
)
|
||||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_type(self):
|
def file_type(self):
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
"""
|
|
||||||
Pure PDF page operations used by documents.bulk_edit.
|
|
||||||
|
|
||||||
This module deliberately knows nothing about Django, Celery or the documents
|
|
||||||
app: callers resolve documents, choose output paths and queue work. Every
|
|
||||||
function that writes a PDF removes unreferenced resources before saving.
|
|
||||||
|
|
||||||
pikepdf is always called as ``pikepdf.open(...)`` / ``pikepdf.new()`` (never
|
|
||||||
``from pikepdf import open``) so tests can patch those module attributes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
from types import TracebackType
|
|
||||||
|
|
||||||
|
|
||||||
class PageSpec(NamedTuple):
|
|
||||||
"""One page of an output PDF: a 1-indexed source page, optionally rotated."""
|
|
||||||
|
|
||||||
page: int
|
|
||||||
rotate: int = 0 # relative degrees, 0 leaves the page alone
|
|
||||||
|
|
||||||
|
|
||||||
def _require_positive(pages: Iterable[int]) -> None:
|
|
||||||
for page in pages:
|
|
||||||
if page < 1:
|
|
||||||
raise ValueError(f"Page numbers start at 1, got {page}")
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
|
|
||||||
"""
|
|
||||||
Rotate every page relatively on the opened document, not a rebuild, so Info,
|
|
||||||
XMP and outlines are kept. ``src`` is not modified.
|
|
||||||
"""
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
for page in pdf.pages:
|
|
||||||
page.rotate(degrees, relative=True)
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(dst)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
|
|
||||||
"""
|
|
||||||
Remove 1-indexed pages from the opened document, not a rebuild, so Info, XMP
|
|
||||||
and outlines are kept. ``src`` is not modified.
|
|
||||||
|
|
||||||
Duplicates are ignored. Pages are removed highest first so earlier removals
|
|
||||||
never shift the index of later ones.
|
|
||||||
"""
|
|
||||||
unique = sorted(set(pages))
|
|
||||||
_require_positive(unique)
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
for page_num in reversed(unique):
|
|
||||||
del pdf.pages[page_num - 1]
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(dst)
|
|
||||||
|
|
||||||
|
|
||||||
def build_pdfs(
|
|
||||||
src: Path,
|
|
||||||
outputs: Sequence[tuple[Sequence[PageSpec], Callable[[], Path]]],
|
|
||||||
) -> list[Path]:
|
|
||||||
"""
|
|
||||||
Build one new PDF per output from pages of ``src``, opening ``src`` once.
|
|
||||||
|
|
||||||
Each output is ``(page_specs, make_dst)``. Every page number is checked against
|
|
||||||
``src`` before any output is built, and ``make_dst`` is called after that
|
|
||||||
output's pages are copied and immediately before it is saved, so a bad page
|
|
||||||
number in any output never leaves a destination behind. Document-level data
|
|
||||||
(Info, XMP, outlines) is not carried over. Returns the written paths in output
|
|
||||||
order.
|
|
||||||
"""
|
|
||||||
for specs, _ in outputs:
|
|
||||||
_require_positive(spec.page for spec in specs)
|
|
||||||
|
|
||||||
written: list[Path] = []
|
|
||||||
with pikepdf.open(src) as source:
|
|
||||||
page_count = len(source.pages)
|
|
||||||
for specs, _ in outputs:
|
|
||||||
for spec in specs:
|
|
||||||
if spec.page > page_count:
|
|
||||||
raise IndexError(
|
|
||||||
f"Page {spec.page} is out of range, the PDF has "
|
|
||||||
f"{page_count} pages",
|
|
||||||
)
|
|
||||||
for specs, make_dst in outputs:
|
|
||||||
dst = pikepdf.new()
|
|
||||||
for spec in specs:
|
|
||||||
dst.pages.append(source.pages[spec.page - 1])
|
|
||||||
if spec.rotate:
|
|
||||||
dst.pages[-1].rotate(spec.rotate, relative=True)
|
|
||||||
dst.remove_unreferenced_resources()
|
|
||||||
path = make_dst()
|
|
||||||
dst.save(path)
|
|
||||||
dst.close()
|
|
||||||
written.append(path)
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def validate_page_operations(
|
|
||||||
operations: Sequence[Mapping[str, int]],
|
|
||||||
*,
|
|
||||||
single_output: bool,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Validate ``edit_pdf`` style operations and return the output document count.
|
|
||||||
|
|
||||||
Each operation has ``page`` and optionally ``rotate`` and ``doc`` (the output
|
|
||||||
document index, default 0). The bounds rule is kept as it was: a ``doc`` index
|
|
||||||
must be below the number of operations.
|
|
||||||
"""
|
|
||||||
if not operations:
|
|
||||||
raise ValueError("Output document index is out of bounds")
|
|
||||||
|
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
|
||||||
if single_output and max_idx > 0:
|
|
||||||
raise ValueError("Multiple output documents specified")
|
|
||||||
|
|
||||||
if any(
|
|
||||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations) for op in operations
|
|
||||||
):
|
|
||||||
raise ValueError("Output document index is out of bounds")
|
|
||||||
|
|
||||||
return max_idx + 1
|
|
||||||
|
|
||||||
|
|
||||||
def needs_decrypt(src: Path) -> bool:
|
|
||||||
"""
|
|
||||||
True if ``src`` is encrypted. A PDF that needs a password to open at all
|
|
||||||
counts as encrypted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
return bool(pdf.is_encrypted)
|
|
||||||
except pikepdf.PasswordError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
|
|
||||||
"""
|
|
||||||
Write an unencrypted copy of ``src`` and return its path.
|
|
||||||
|
|
||||||
``make_dst`` is only called once the password has been accepted, so a wrong
|
|
||||||
password never leaves a destination behind.
|
|
||||||
"""
|
|
||||||
with pikepdf.open(src, password=password) as pdf:
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
dst = make_dst()
|
|
||||||
pdf.save(dst)
|
|
||||||
return dst
|
|
||||||
|
|
||||||
|
|
||||||
class PdfMerger:
|
|
||||||
"""
|
|
||||||
Accumulates the pages of several PDFs into one new PDF.
|
|
||||||
|
|
||||||
``add`` raises if a source cannot be read; deciding whether to skip it is the
|
|
||||||
caller's policy. Use as a context manager so the merged PDF is closed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._pdf = pikepdf.new()
|
|
||||||
self._version: str = self._pdf.pdf_version
|
|
||||||
|
|
||||||
def __enter__(self) -> PdfMerger:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
tb: TracebackType | None,
|
|
||||||
) -> None:
|
|
||||||
self._pdf.close()
|
|
||||||
|
|
||||||
def add(self, path: Path) -> None:
|
|
||||||
with pikepdf.open(str(path)) as pdf:
|
|
||||||
self._version = max(self._version, pdf.pdf_version)
|
|
||||||
self._pdf.pages.extend(pdf.pages)
|
|
||||||
|
|
||||||
def save(self, dst: Path) -> None:
|
|
||||||
self._pdf.remove_unreferenced_resources()
|
|
||||||
self._pdf.save(dst, min_version=self._version)
|
|
||||||
@@ -511,6 +511,28 @@ def restrict_queryset_to_visible(
|
|||||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||||
|
|
||||||
|
|
||||||
|
def user_is_active_superuser(user: User | None) -> bool:
|
||||||
|
"""
|
||||||
|
True only for an active, authenticated superuser.
|
||||||
|
"""
|
||||||
|
return user is not None and user_is_unrestricted(user)
|
||||||
|
|
||||||
|
|
||||||
|
def restrict_queryset_to_owner_visible(
|
||||||
|
queryset: QuerySet[ModelT],
|
||||||
|
user: User | None,
|
||||||
|
perm: str,
|
||||||
|
) -> QuerySet[ModelT]:
|
||||||
|
"""
|
||||||
|
Like ``restrict_queryset_to_visible``, but ``user=None`` sees only unowned
|
||||||
|
rows (``permitted_object_ids(None, ...)`` semantics) instead of every row.
|
||||||
|
See ``user_is_active_superuser``.
|
||||||
|
"""
|
||||||
|
if user_is_active_superuser(user):
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||||
|
|
||||||
|
|
||||||
def permitted_document_ids(
|
def permitted_document_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -926,6 +926,7 @@ class TantivyBackend:
|
|||||||
sort_field: str | None = None,
|
sort_field: str | None = None,
|
||||||
sort_reverse: bool = False,
|
sort_reverse: bool = False,
|
||||||
search_mode: SearchMode = SearchMode.QUERY,
|
search_mode: SearchMode = SearchMode.QUERY,
|
||||||
|
filter_query: str | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
@@ -941,6 +942,7 @@ class TantivyBackend:
|
|||||||
sort_field: Field to sort by (None for relevance ranking)
|
sort_field: Field to sort by (None for relevance ranking)
|
||||||
sort_reverse: Whether to reverse the sort order
|
sort_reverse: Whether to reverse the sort order
|
||||||
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
||||||
|
filter_query: Optional advanced query results must also match
|
||||||
limit: Maximum number of IDs to return (None = all matching docs)
|
limit: Maximum number of IDs to return (None = all matching docs)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -948,6 +950,13 @@ class TantivyBackend:
|
|||||||
"""
|
"""
|
||||||
self._ensure_open()
|
self._ensure_open()
|
||||||
user_query = self._parse_query(query, search_mode)
|
user_query = self._parse_query(query, search_mode)
|
||||||
|
if filter_query is not None:
|
||||||
|
user_query = user_query.and_must_match(
|
||||||
|
tantivy.Query.const_score_query(
|
||||||
|
self._parse_query(filter_query, SearchMode.QUERY),
|
||||||
|
0.0, # zero so doesn't affect relevance
|
||||||
|
),
|
||||||
|
)
|
||||||
final_query = self._apply_permission_filter(user_query, user)
|
final_query = self._apply_permission_filter(user_query, user)
|
||||||
|
|
||||||
searcher = self._index.searcher()
|
searcher = self._index.searcher()
|
||||||
|
|||||||
@@ -2144,8 +2144,6 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("pages must be a list")
|
raise serializers.ValidationError("pages must be a list")
|
||||||
if not all(isinstance(i, int) for i in parameters["pages"]):
|
if not all(isinstance(i, int) for i in parameters["pages"]):
|
||||||
raise serializers.ValidationError("pages must be a list of integers")
|
raise serializers.ValidationError("pages must be a list of integers")
|
||||||
if any(i < 1 for i in parameters["pages"]):
|
|
||||||
raise serializers.ValidationError("pages must be positive integers")
|
|
||||||
|
|
||||||
def _validate_parameters_merge(self, parameters) -> None:
|
def _validate_parameters_merge(self, parameters) -> None:
|
||||||
if "delete_originals" in parameters:
|
if "delete_originals" in parameters:
|
||||||
|
|||||||
@@ -835,6 +835,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
for filters in (
|
for filters in (
|
||||||
{"text": "new doc 2017-03-16"},
|
{"text": "new doc 2017-03-16"},
|
||||||
{"title_search": "apple"},
|
{"title_search": "apple"},
|
||||||
|
{"title_search": "apple", "query": "created:[-1 year to now]"},
|
||||||
):
|
):
|
||||||
with self.subTest(filters=filters):
|
with self.subTest(filters=filters):
|
||||||
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
||||||
@@ -886,7 +887,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
"all": True,
|
"all": True,
|
||||||
"filters": {
|
"filters": {
|
||||||
"text": "B",
|
"text": "B",
|
||||||
"query": "c1",
|
"title_search": "c1",
|
||||||
},
|
},
|
||||||
"method": "set_storage_path",
|
"method": "set_storage_path",
|
||||||
"parameters": {"storage_path": self.sp1.id},
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
@@ -1843,36 +1844,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
|
||||||
def test_bulk_edit_delete_pages_rejects_pages_below_one(self, m) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A legacy delete_pages bulk edit
|
|
||||||
WHEN:
|
|
||||||
- API to bulk edit is called with a page number below 1
|
|
||||||
THEN:
|
|
||||||
- API returns HTTP 400
|
|
||||||
- delete_pages is not called
|
|
||||||
"""
|
|
||||||
self.setup_mock(m, "delete_pages")
|
|
||||||
|
|
||||||
for pages in ([0], [-1], [1, 0]):
|
|
||||||
with self.subTest(pages=pages):
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": "delete_pages",
|
|
||||||
"parameters": {"pages": pages},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(b"pages must be positive integers", response.content)
|
|
||||||
m.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
def test_rotate_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
|
|||||||
@@ -245,8 +245,68 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["count"], 1)
|
self.assertEqual(response.data["count"], 1)
|
||||||
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
||||||
|
|
||||||
|
def test_simple_search_combined_with_query_filter(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with the term in its title, created this year
|
||||||
|
- A document with the term only in its content, created this year
|
||||||
|
- A document with the term in its title, created years ago
|
||||||
|
WHEN:
|
||||||
|
- A title or text search is combined with a relative created
|
||||||
|
date query, as the filter editor sends it
|
||||||
|
THEN:
|
||||||
|
- The simple search keeps its own field scope and the query only
|
||||||
|
narrows the results (issue #14436)
|
||||||
|
"""
|
||||||
|
this_year = timezone.localdate().replace(month=1, day=1)
|
||||||
|
title_match = Document.objects.create(
|
||||||
|
title="foo report",
|
||||||
|
content="nothing relevant",
|
||||||
|
checksum="F1",
|
||||||
|
created=this_year,
|
||||||
|
pk=21,
|
||||||
|
)
|
||||||
|
content_match = Document.objects.create(
|
||||||
|
title="something else",
|
||||||
|
content="foo appears here",
|
||||||
|
checksum="F2",
|
||||||
|
created=this_year,
|
||||||
|
pk=22,
|
||||||
|
)
|
||||||
|
old_title_match = Document.objects.create(
|
||||||
|
title="foo archive",
|
||||||
|
content="nothing relevant",
|
||||||
|
checksum="F3",
|
||||||
|
created=datetime.date(2001, 1, 1),
|
||||||
|
pk=23,
|
||||||
|
)
|
||||||
|
backend = get_backend()
|
||||||
|
for doc in (title_match, content_match, old_title_match):
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
query = "created:[-1 year to now]"
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
{"title_search": "foo", "query": query},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
[r["id"] for r in response.data["results"]],
|
||||||
|
[title_match.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
{"text": "foo", "query": query},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertCountEqual(
|
||||||
|
[r["id"] for r in response.data["results"]],
|
||||||
|
[title_match.id, content_match.id],
|
||||||
|
)
|
||||||
|
|
||||||
def test_search_rejects_multiple_search_modes(self) -> None:
|
def test_search_rejects_multiple_search_modes(self) -> None:
|
||||||
response = self.client.get("/api/documents/?text=bank&query=bank")
|
response = self.client.get("/api/documents/?text=bank&title_search=bank")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data["detail"],
|
response.data["detail"],
|
||||||
@@ -2077,29 +2137,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data["documents"]), 1)
|
self.assertEqual(len(response.data["documents"]), 1)
|
||||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||||
|
|
||||||
def test_global_search_returns_latest_version_content(self) -> None:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="bank statement",
|
|
||||||
content="superseded content",
|
|
||||||
checksum="GSV1",
|
|
||||||
pk=23,
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="bank statement v2",
|
|
||||||
content="latest content",
|
|
||||||
checksum="GSV2",
|
|
||||||
pk=24,
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_authenticate(self.user)
|
|
||||||
|
|
||||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
|
||||||
self.assertEqual(returned.get(root.id), "latest content")
|
|
||||||
|
|
||||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||||
user1 = UserFactory(username="mail-search-user")
|
user1 = UserFactory(username="mail-search-user")
|
||||||
user2 = UserFactory(username="other-mail-search-user")
|
user2 = UserFactory(username="other-mail-search-user")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import shutil
|
import shutil
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -23,7 +23,6 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.pdf_ops import PageSpec
|
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
@@ -797,14 +796,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.img_doc.save()
|
self.img_doc.save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fake_decrypt(
|
def mock_password_required_pdf(
|
||||||
src: Path,
|
mock_open: mock.Mock,
|
||||||
make_dst: Callable[[], Path],
|
fake_pdf: mock.Mock,
|
||||||
password: str,
|
) -> None:
|
||||||
) -> Path:
|
password_context = mock.MagicMock()
|
||||||
dst = make_dst()
|
password_context.__enter__.return_value = fake_pdf
|
||||||
dst.write_bytes(b"password removed")
|
mock_open.side_effect = [
|
||||||
return dst
|
pikepdf.PasswordError("password required"),
|
||||||
|
password_context,
|
||||||
|
]
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge(self, mock_consume_file) -> None:
|
def test_merge(self, mock_consume_file) -> None:
|
||||||
@@ -849,12 +850,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.pdf_ops.PdfMerger")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge_uses_latest_version_source_for_root_selection(
|
def test_merge_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_merger,
|
mock_open_pdf,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
||||||
shutil.copy(self.doc2.source_path, version_file)
|
shutil.copy(self.doc2.source_path, version_file)
|
||||||
@@ -865,14 +866,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
merger = mock_merger.return_value.__enter__.return_value
|
fake_pdf = mock.MagicMock()
|
||||||
merger.save.side_effect = lambda dst: shutil.copy(version.source_path, dst)
|
fake_pdf.pdf_version = "1.7"
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.merge([self.doc2.id])
|
result = bulk_edit.merge([self.doc2.id])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
merger.add.assert_called_once_with(version.source_path)
|
mock_open_pdf.assert_called_once_with(str(version.source_path))
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1034,18 +1037,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.delay")
|
@mock.patch("documents.tasks.consume_file.delay")
|
||||||
@mock.patch("documents.pdf_ops.PdfMerger.add")
|
@mock.patch("pikepdf.open")
|
||||||
def test_merge_with_errors(self, mock_add, mock_consume_file) -> None:
|
def test_merge_with_errors(self, mock_open_pdf, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Merge action is called with 2 documents
|
- Merge action is called with 2 documents
|
||||||
- Error occurs when adding both files
|
- Error occurs when opening both files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_add.side_effect = Exception("Error opening PDF")
|
mock_open_pdf.side_effect = Exception("Error opening PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1082,12 +1085,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_split_uses_latest_version_source_for_root_selection(
|
def test_split_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_build_pdfs,
|
mock_open_pdf,
|
||||||
mock_group,
|
mock_group,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
||||||
@@ -1099,15 +1102,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
mock_build_pdfs.return_value = [version.source_path, version.source_path]
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_build_pdfs.call_args.args[0], version.source_path)
|
mock_open_pdf.assert_called_once_with(version.source_path)
|
||||||
self.assertEqual(mock_consume_file.call_count, 2)
|
mock_consume_file.assert_not_called()
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1195,18 +1200,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.doc2.archive_serial_number, 222)
|
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_split_with_errors(self, mock_build_pdfs, mock_consume_file) -> None:
|
def test_split_with_errors(self, mock_save_pdf, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Split action is called with 1 document and 2 page groups
|
- Split action is called with 1 document and 2 page groups
|
||||||
- Error occurs when building the files
|
- Error occurs when saving the files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_build_pdfs.side_effect = Exception("Error building PDFs")
|
mock_save_pdf.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [[1, 2], [3]]
|
pages = [[1, 2], [3]]
|
||||||
|
|
||||||
@@ -1241,10 +1246,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.rotate_pdf")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_rotate_with_error(
|
def test_rotate_with_error(
|
||||||
self,
|
self,
|
||||||
mock_rotate_pdf,
|
mock_pdf_save,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1252,11 +1257,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Rotate action is called with 2 documents
|
- Rotate action is called with 2 documents
|
||||||
- Rotating the PDF raises an error
|
- PikePDF raises an error
|
||||||
THEN:
|
THEN:
|
||||||
- Rotate action should be called 0 times
|
- Rotate action should be called 0 times
|
||||||
"""
|
"""
|
||||||
mock_rotate_pdf.side_effect = Exception("Error rotating PDF")
|
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1291,10 +1296,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.rotate_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_rotate_pdf,
|
mock_open,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1303,6 +1308,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.rotate(
|
result = bulk_edit.rotate(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1311,35 +1319,26 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_rotate_pdf.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
def test_delete_pages(
|
def test_delete_pages(self, mock_magic, mock_pdf_save, mock_consume_delay) -> None:
|
||||||
self,
|
|
||||||
mock_magic,
|
|
||||||
mock_remove_pages,
|
|
||||||
mock_consume_delay,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
THEN:
|
THEN:
|
||||||
- The pages are removed from the document's source PDF
|
- Save should be called once
|
||||||
- A new version should be enqueued via consume_file
|
- A new version should be enqueued via consume_file
|
||||||
"""
|
"""
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
result = bulk_edit.delete_pages(doc_ids, pages)
|
result = bulk_edit.delete_pages(doc_ids, pages)
|
||||||
mock_remove_pages.assert_called_once_with(
|
mock_pdf_save.assert_called_once()
|
||||||
self.doc2.source_path,
|
|
||||||
mock.ANY,
|
|
||||||
[1, 3],
|
|
||||||
)
|
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
||||||
@@ -1351,10 +1350,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.open")
|
||||||
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_remove_pages,
|
mock_open,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1363,6 +1362,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.delete_pages(
|
result = bulk_edit.delete_pages(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1371,26 +1373,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_remove_pages.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_delete_pages_with_error(
|
def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay) -> None:
|
||||||
self,
|
|
||||||
mock_remove_pages,
|
|
||||||
mock_consume_delay,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
- Removing the pages raises an error
|
- PikePDF raises an error
|
||||||
THEN:
|
THEN:
|
||||||
|
- Save should be called once
|
||||||
- No new version should be enqueued
|
- No new version should be enqueued
|
||||||
"""
|
"""
|
||||||
mock_remove_pages.side_effect = Exception("Error removing pages")
|
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
|
|
||||||
@@ -1420,41 +1419,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
|
||||||
def test_edit_pdf_maps_operations_to_outputs(
|
|
||||||
self,
|
|
||||||
mock_consume_file: mock.Mock,
|
|
||||||
mock_build_pdfs: mock.Mock,
|
|
||||||
mock_group: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing document
|
|
||||||
WHEN:
|
|
||||||
- edit_pdf is called with operations interleaved across two outputs,
|
|
||||||
some of them rotated
|
|
||||||
THEN:
|
|
||||||
- Each output is built from its own operations, in operation order,
|
|
||||||
with the requested rotation
|
|
||||||
"""
|
|
||||||
mock_build_pdfs.return_value = [self.doc2.source_path, self.doc2.source_path]
|
|
||||||
mock_group.return_value.delay.return_value = None
|
|
||||||
operations = [
|
|
||||||
{"page": 3, "doc": 1},
|
|
||||||
{"page": 1, "doc": 0, "rotate": 90},
|
|
||||||
{"page": 2, "doc": 1, "rotate": 180},
|
|
||||||
]
|
|
||||||
|
|
||||||
bulk_edit.edit_pdf([self.doc2.id], operations)
|
|
||||||
|
|
||||||
outputs = mock_build_pdfs.call_args.args[1]
|
|
||||||
self.assertEqual(
|
|
||||||
[specs for specs, _ in outputs],
|
|
||||||
[[PageSpec(1, 90)], [PageSpec(3), PageSpec(2, 180)]],
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
||||||
@@ -1570,10 +1534,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.new")
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_build_pdfs,
|
mock_open,
|
||||||
|
mock_new,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1582,7 +1548,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
mock_build_pdfs.return_value = [Path("edited.pdf")]
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
output_pdf = mock.MagicMock()
|
||||||
|
output_pdf.pages = []
|
||||||
|
mock_new.return_value = output_pdf
|
||||||
|
|
||||||
result = bulk_edit.edit_pdf(
|
result = bulk_edit.edit_pdf(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1592,7 +1563,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_build_pdfs.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@@ -1618,6 +1589,31 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.group")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
def test_edit_pdf_open_failure(
|
||||||
|
self,
|
||||||
|
mock_consume_file: mock.Mock,
|
||||||
|
mock_group: mock.Mock,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document
|
||||||
|
WHEN:
|
||||||
|
- edit_pdf fails to open PDF
|
||||||
|
THEN:
|
||||||
|
- Task group is not called
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc2.id]
|
||||||
|
operations = [
|
||||||
|
{"page": 9999}, # invalid page, forces error during PDF load
|
||||||
|
]
|
||||||
|
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
bulk_edit.edit_pdf(doc_ids, operations)
|
||||||
|
mock_group.assert_not_called()
|
||||||
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
||||||
@@ -1644,15 +1640,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
|
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
|
||||||
|
for operations in ([], [{"page": 1, "doc": 2**32}]):
|
||||||
|
with self.subTest(operations=operations):
|
||||||
|
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
|
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
||||||
|
bulk_edit.edit_pdf([self.doc2.id], operations)
|
||||||
|
|
||||||
|
mock_open.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_update_document(
|
def test_remove_password_update_document(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1661,7 +1665,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()]
|
||||||
|
fake_pdf.is_encrypted = True
|
||||||
|
|
||||||
|
def save_side_effect(target_path):
|
||||||
|
Path(target_path).write_bytes(b"new pdf content")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1670,8 +1683,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fake_pdf.remove_unreferenced_resources.assert_called_once()
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
@@ -1684,15 +1703,40 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
||||||
self.assertIsNotNone(task_kwargs["overrides"])
|
self.assertIsNotNone(task_kwargs["overrides"])
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
|
def test_remove_password_update_document_skips_unencrypted_pdf(
|
||||||
|
self,
|
||||||
|
mock_open,
|
||||||
|
mock_mkdtemp,
|
||||||
|
mock_consume_delay,
|
||||||
|
) -> None:
|
||||||
|
doc = self.doc1
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.is_encrypted = False
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
|
result = bulk_edit.remove_password(
|
||||||
|
[doc.id],
|
||||||
|
password="secret",
|
||||||
|
update_document=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, "OK")
|
||||||
|
mock_open.assert_called_once_with(doc.source_path)
|
||||||
|
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
||||||
|
fake_pdf.save.assert_not_called()
|
||||||
|
mock_mkdtemp.assert_not_called()
|
||||||
|
mock_consume_delay.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_update_document_uses_source_paths(
|
def test_remove_password_update_document_uses_source_paths(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1703,7 +1747,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path):
|
||||||
|
Path(target_path).write_bytes(b"new pdf content")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1713,19 +1764,22 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(source_file)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(source_file, mock.ANY, "secret")
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(source_file),
|
||||||
|
mock.call(source_file, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1734,7 +1788,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="A version 1",
|
title="A version 1",
|
||||||
root_document=self.doc1,
|
root_document=self.doc1,
|
||||||
)
|
)
|
||||||
mock_decrypt.return_value = Path("unprotected.pdf")
|
fake_pdf = mock.MagicMock()
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[self.doc1.id],
|
[self.doc1.id],
|
||||||
@@ -1744,11 +1799,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(self.doc1.source_path)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(
|
mock_open.call_args_list,
|
||||||
self.doc1.source_path,
|
[
|
||||||
mock.ANY,
|
mock.call(self.doc1.source_path),
|
||||||
"secret",
|
mock.call(self.doc1.source_path, password="secret"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@@ -1756,12 +1812,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_creates_consumable_document(
|
def test_remove_password_creates_consumable_document(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1771,7 +1825,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password"
|
temp_dir = self.dirs.scratch_dir / "remove-password"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path: Path) -> None:
|
||||||
|
target_path.write_bytes(b"password removed")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
user = User.objects.create(username="owner")
|
user = User.objects.create(username="owner")
|
||||||
@@ -1786,7 +1848,13 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
self.assertEqual(
|
||||||
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
call_kwargs = mock_consume_file.call_args.kwargs
|
call_kwargs = mock_consume_file.call_args.kwargs
|
||||||
consumable_document = call_kwargs["input_doc"]
|
consumable_document = call_kwargs["input_doc"]
|
||||||
@@ -1808,18 +1876,21 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.chord")
|
@mock.patch("documents.bulk_edit.chord")
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=False)
|
@mock.patch("pikepdf.open")
|
||||||
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
mock_chord: mock.Mock,
|
mock_chord: mock.Mock,
|
||||||
mock_delete: mock.Mock,
|
mock_delete: mock.Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
doc = self.doc2
|
doc = self.doc2
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.is_encrypted = False
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1829,8 +1900,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
mock_open.assert_called_once_with(doc.source_path)
|
||||||
mock_decrypt.assert_not_called()
|
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
||||||
|
fake_pdf.save.assert_not_called()
|
||||||
|
mock_mkdtemp.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_not_called()
|
mock_chord.assert_not_called()
|
||||||
@@ -1841,12 +1914,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_deletes_original(
|
def test_remove_password_deletes_original(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1857,7 +1928,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path: Path) -> None:
|
||||||
|
target_path.write_bytes(b"password removed")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
mock_chord.return_value.delay.return_value = None
|
mock_chord.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
@@ -1869,23 +1948,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
self.assertEqual(
|
||||||
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_called_once()
|
mock_chord.assert_called_once()
|
||||||
mock_chord.return_value.delay.assert_called_once()
|
mock_chord.return_value.delay.assert_called_once()
|
||||||
mock_delete.si.assert_called_once_with([doc.id])
|
mock_delete.si.assert_called_once_with([doc.id])
|
||||||
|
|
||||||
@mock.patch(
|
@mock.patch("pikepdf.open")
|
||||||
"documents.pdf_ops.decrypt_pdf",
|
def test_remove_password_open_failure(self, mock_open: mock.Mock) -> None:
|
||||||
side_effect=RuntimeError("wrong password"),
|
mock_open.side_effect = RuntimeError("wrong password")
|
||||||
)
|
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_failure_raises_value_error(
|
|
||||||
self,
|
|
||||||
mock_needs_decrypt: mock.Mock,
|
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
with self.assertRaises(ValueError) as exc:
|
with self.assertRaises(ValueError) as exc:
|
||||||
bulk_edit.remove_password([self.doc1.id], password="secret")
|
bulk_edit.remove_password([self.doc1.id], password="secret")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.test import override_settings
|
|||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
from sklearn.neural_network import MLPClassifier
|
||||||
|
|
||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None:
|
|||||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredictWithThresholdSingleClass:
|
||||||
|
@pytest.fixture
|
||||||
|
def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]:
|
||||||
|
X = np.random.default_rng(0).random((2, 5))
|
||||||
|
return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1]
|
||||||
|
|
||||||
|
def test_returns_only_class(
|
||||||
|
self,
|
||||||
|
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A classifier fitted on a single class (every document shares one label)
|
||||||
|
WHEN:
|
||||||
|
- A prediction is requested with the threshold disabled
|
||||||
|
THEN:
|
||||||
|
- No error is raised and the only class is returned
|
||||||
|
"""
|
||||||
|
classifier, X = single_class_model
|
||||||
|
|
||||||
|
assert _predict_with_threshold(classifier, X, threshold=0.0) == 3
|
||||||
|
|
||||||
|
def test_applies_threshold(
|
||||||
|
self,
|
||||||
|
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A classifier fitted on a single class
|
||||||
|
WHEN:
|
||||||
|
- A prediction is requested
|
||||||
|
THEN:
|
||||||
|
- The class's confidence is still compared against the threshold
|
||||||
|
"""
|
||||||
|
classifier, X = single_class_model
|
||||||
|
confidence = classifier.predict_proba(X)[0][0]
|
||||||
|
|
||||||
|
assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3
|
||||||
|
assert (
|
||||||
|
_predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestPreprocessContent:
|
class TestPreprocessContent:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def samples(self) -> Path:
|
def samples(self) -> Path:
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ from documents.tests.utils import GetConsumerMixin
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -1683,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
|
|||||||
self.assertEqual(base.actor_id, 2)
|
self.assertEqual(base.actor_id, 2)
|
||||||
self.assertEqual(base.version_label, "v2")
|
self.assertEqual(base.version_label, "v2")
|
||||||
|
|
||||||
|
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with view and change granted directly to one user and
|
||||||
|
to a group with another user as a member
|
||||||
|
WHEN:
|
||||||
|
- Overrides are built from the document
|
||||||
|
THEN:
|
||||||
|
- The user lists hold only the directly granted user
|
||||||
|
- The group lists hold the group
|
||||||
|
"""
|
||||||
|
direct_user = UserFactory()
|
||||||
|
group_member = UserFactory()
|
||||||
|
group = Group.objects.create(name="group")
|
||||||
|
group_member.groups.add(group)
|
||||||
|
document = DocumentFactory()
|
||||||
|
grant_object(direct_user, document, "view_document", "change_document")
|
||||||
|
grant_object(group, document, "view_document", "change_document")
|
||||||
|
|
||||||
|
overrides = DocumentMetadataOverrides.from_document(document)
|
||||||
|
|
||||||
|
self.assertEqual(overrides.view_users, [direct_user.pk])
|
||||||
|
self.assertEqual(overrides.change_users, [direct_user.pk])
|
||||||
|
self.assertEqual(overrides.view_groups, [group.pk])
|
||||||
|
self.assertEqual(overrides.change_groups, [group.pk])
|
||||||
|
|
||||||
|
|
||||||
class TestBarcodeApplyDetectedASN(TestCase):
|
class TestBarcodeApplyDetectedASN(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -423,35 +423,3 @@ class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
|||||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||||
assert result["content"] == "own-content"
|
assert result["content"] == "own-content"
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
|
|
||||||
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document, findable by title, whose own content differs
|
|
||||||
from its latest version's
|
|
||||||
WHEN:
|
|
||||||
- Using the global search endpoint's db_only mode
|
|
||||||
THEN:
|
|
||||||
- The response shows the latest version's content, resolved by
|
|
||||||
GlobalSearchView's own effective_content annotation
|
|
||||||
- There is no per-instance fallback query
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(title="findme", content="own-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get(
|
|
||||||
"/api/search/?query=findme&db_only=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
|
||||||
assert result["content"] == "version-content"
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
@@ -4,13 +4,16 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import empty_trash
|
from documents.tasks import empty_trash
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
class TestDocument(TestCase):
|
class TestDocument(TestCase):
|
||||||
@@ -257,3 +260,72 @@ def test_suggestion_content() -> None:
|
|||||||
)
|
)
|
||||||
doc.content = content_over_limit
|
doc.content = content_over_limit
|
||||||
assert doc.suggestion_content == expected_cropped_content
|
assert doc.suggestion_content == expected_cropped_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicFilenameLength:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_parser_registry(self, mocker: MockerFixture) -> None:
|
||||||
|
# The parser registry reads ApplicationConfiguration from the database
|
||||||
|
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
|
||||||
|
mocker.patch(
|
||||||
|
"documents.models.get_default_file_extension",
|
||||||
|
side_effect=extensions.__getitem__,
|
||||||
|
)
|
||||||
|
|
||||||
|
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("title", "archive", "counter", "max_bytes", "tail"),
|
||||||
|
[
|
||||||
|
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
|
||||||
|
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
|
||||||
|
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
|
||||||
|
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
|
||||||
|
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_long_title_keeps_tail_within_limit(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
archive: bool,
|
||||||
|
counter: int,
|
||||||
|
max_bytes: int,
|
||||||
|
tail: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document whose title alone is longer than the byte limit
|
||||||
|
WHEN:
|
||||||
|
- The public file name is generated
|
||||||
|
THEN:
|
||||||
|
- The name ends with the counter and extension
|
||||||
|
- The name fits in the limit
|
||||||
|
"""
|
||||||
|
name = DocumentFactory.build(
|
||||||
|
title=title,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
).get_public_filename(
|
||||||
|
archive=archive,
|
||||||
|
counter=counter,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert name.endswith(tail)
|
||||||
|
assert len(name.encode()) <= max_bytes
|
||||||
|
|
||||||
|
def test_long_title_counters_give_distinct_names(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document whose title alone is longer than the byte limit
|
||||||
|
WHEN:
|
||||||
|
- Public file names are generated with increasing counters
|
||||||
|
THEN:
|
||||||
|
- Each counter produces a distinct name
|
||||||
|
"""
|
||||||
|
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
|
||||||
|
|
||||||
|
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
|
||||||
|
|
||||||
|
assert len(set(names)) == 3
|
||||||
|
assert all(len(n.encode()) <= self.LIMIT for n in names)
|
||||||
@@ -50,6 +50,7 @@ from documents.settings import EXPORTER_FILE_NAME
|
|||||||
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
@@ -1402,6 +1403,45 @@ class TestCryptExportImport(
|
|||||||
social_token = SocialToken.objects.first()
|
social_token = SocialToken.objects.first()
|
||||||
self.assertIsNotNone(social_token)
|
self.assertIsNotNone(social_token)
|
||||||
|
|
||||||
|
def test_import_passphrase_with_float_field(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An application configuration with a float field set
|
||||||
|
WHEN:
|
||||||
|
- Export and import are run with a passphrase
|
||||||
|
THEN:
|
||||||
|
- The import succeeds and the float value is preserved
|
||||||
|
"""
|
||||||
|
ApplicationConfiguration.objects.update_or_create(
|
||||||
|
pk=1,
|
||||||
|
defaults={"rotate_pages_threshold": 12.5},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
"--no-progress-bar",
|
||||||
|
"--passphrase",
|
||||||
|
"securepassword",
|
||||||
|
self.target,
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ApplicationConfiguration.objects.all().delete()
|
||||||
|
|
||||||
|
call_command(
|
||||||
|
"document_importer",
|
||||||
|
"--no-progress-bar",
|
||||||
|
"--passphrase",
|
||||||
|
"securepassword",
|
||||||
|
self.target,
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
ApplicationConfiguration.objects.get().rotate_pages_threshold,
|
||||||
|
12.5,
|
||||||
|
)
|
||||||
|
|
||||||
def test_import_crypt_no_passphrase(self) -> None:
|
def test_import_crypt_no_passphrase(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1537,3 +1577,70 @@ class TestExportDerivedFileNameCollisions:
|
|||||||
assert (
|
assert (
|
||||||
target / record[EXPORTER_ARCHIVE_NAME]
|
target / record[EXPORTER_ARCHIVE_NAME]
|
||||||
).read_text() == f"archive {pk}"
|
).read_text() == f"archive {pk}"
|
||||||
|
|
||||||
|
def test_long_titles_export_within_file_name_limit(
|
||||||
|
self,
|
||||||
|
paperless_dirs: PaperlessDirs,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two documents with the same created date and the same title
|
||||||
|
- The title alone is longer than 255 bytes and contains umlauts
|
||||||
|
WHEN:
|
||||||
|
- The exporter runs
|
||||||
|
THEN:
|
||||||
|
- The export finishes with distinct original, thumbnail and archive names
|
||||||
|
- Every exported file name fits in the byte limit
|
||||||
|
- Each exported file holds its own document's content
|
||||||
|
"""
|
||||||
|
for pk in (1, 2):
|
||||||
|
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
|
||||||
|
f"original {pk}".encode(),
|
||||||
|
)
|
||||||
|
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
|
||||||
|
f"archive {pk}".encode(),
|
||||||
|
)
|
||||||
|
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
|
||||||
|
f"thumbnail {pk}".encode(),
|
||||||
|
)
|
||||||
|
DocumentFactory(
|
||||||
|
pk=pk,
|
||||||
|
title="Bestellbestätigung " * 15,
|
||||||
|
created=date(2026, 10, 15),
|
||||||
|
filename=f"{pk:07}.pdf",
|
||||||
|
archive_filename=f"{pk:07}.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "export"
|
||||||
|
target.mkdir()
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
str(target),
|
||||||
|
"--no-progress-bar",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = json.loads((target / "manifest.json").read_text())
|
||||||
|
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
EXPORTER_FILE_NAME,
|
||||||
|
EXPORTER_THUMBNAIL_NAME,
|
||||||
|
EXPORTER_ARCHIVE_NAME,
|
||||||
|
):
|
||||||
|
assert records[1][key] != records[2][key]
|
||||||
|
|
||||||
|
for pk in (1, 2):
|
||||||
|
record = records[pk]
|
||||||
|
for key, content in (
|
||||||
|
(EXPORTER_FILE_NAME, f"original {pk}"),
|
||||||
|
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
|
||||||
|
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
|
||||||
|
):
|
||||||
|
assert (
|
||||||
|
len(Path(record[key]).name.encode())
|
||||||
|
<= Document.MAX_PUBLIC_FILENAME_BYTES
|
||||||
|
)
|
||||||
|
assert (target / record[key]).read_text() == content
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for documents.pdf_ops.
|
|
||||||
|
|
||||||
These use real PDFs from the sample directories. No database, Celery or mocks.
|
|
||||||
Pages are compared by a hash of their content stream, so page identity and order
|
|
||||||
are easy to assert.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from documents import pdf_ops
|
|
||||||
from documents.pdf_ops import PageSpec
|
|
||||||
|
|
||||||
SRC_ROOT = Path(__file__).parents[2]
|
|
||||||
SAMPLES = Path(__file__).parent / "samples"
|
|
||||||
THREE_PAGES = SAMPLES / "documents" / "originals" / "0000002.pdf"
|
|
||||||
TWELVE_PAGES = SAMPLES / "barcodes" / "split-by-asn-2.pdf"
|
|
||||||
ENCRYPTED = SAMPLES / "password-is-test.pdf"
|
|
||||||
SIGNED = SRC_ROOT / "paperless" / "tests" / "samples" / "tesseract" / "signed.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
def _page_fingerprint(page: pikepdf.Page) -> str:
|
|
||||||
contents = page.obj.get("/Contents")
|
|
||||||
assert contents is not None, "sample page has no /Contents"
|
|
||||||
streams = list(contents) if isinstance(contents, pikepdf.Array) else [contents]
|
|
||||||
return hashlib.sha256(b"".join(s.read_bytes() for s in streams)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprints(path: Path) -> list[str]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return [_page_fingerprint(page) for page in pdf.pages]
|
|
||||||
|
|
||||||
|
|
||||||
def rotations(path: Path) -> list[int]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return [int(page.obj.get("/Rotate", 0)) for page in pdf.pages]
|
|
||||||
|
|
||||||
|
|
||||||
def docinfo_keys(path: Path) -> set[str]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return set(pdf.docinfo.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def constant(path: Path) -> Callable[[], Path]:
|
|
||||||
return lambda: path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def source_fingerprints() -> list[str]:
|
|
||||||
fps = fingerprints(THREE_PAGES)
|
|
||||||
assert len(set(fps)) == 3, "sample must have three distinct pages"
|
|
||||||
return fps
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotatePdf:
|
|
||||||
def test_rotation_is_relative_and_applies_to_every_page(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- It is rotated by 90 degrees, then the result is rotated by 90 again
|
|
||||||
THEN:
|
|
||||||
- Every page is rotated relative to its current rotation
|
|
||||||
- The page content itself is unchanged
|
|
||||||
"""
|
|
||||||
once = tmp_path / "once.pdf"
|
|
||||||
twice = tmp_path / "twice.pdf"
|
|
||||||
|
|
||||||
pdf_ops.rotate_pdf(THREE_PAGES, once, 90)
|
|
||||||
pdf_ops.rotate_pdf(once, twice, 90)
|
|
||||||
|
|
||||||
assert rotations(once) == [90, 90, 90]
|
|
||||||
assert rotations(twice) == [180, 180, 180]
|
|
||||||
assert fingerprints(twice) == fingerprints(THREE_PAGES)
|
|
||||||
|
|
||||||
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF with document info
|
|
||||||
WHEN:
|
|
||||||
- It is rotated
|
|
||||||
THEN:
|
|
||||||
- The document info is still present in the output
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.rotate_pdf(THREE_PAGES, dst, 90)
|
|
||||||
|
|
||||||
assert "/Creator" in docinfo_keys(dst)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemovePages:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("pages", "kept"),
|
|
||||||
[
|
|
||||||
pytest.param([2], [0, 2], id="single"),
|
|
||||||
pytest.param([3, 1], [1], id="unordered"),
|
|
||||||
pytest.param([2, 2], [0, 2], id="duplicates-remove-once"),
|
|
||||||
pytest.param([], [0, 1, 2], id="empty-keeps-everything"),
|
|
||||||
pytest.param([1, 2, 3], [], id="every-page"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_removes_only_the_requested_pages(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
pages: list[int],
|
|
||||||
kept: list[int],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Pages are removed, in any order and possibly repeated
|
|
||||||
THEN:
|
|
||||||
- Exactly the other pages remain, in their original order
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, pages)
|
|
||||||
|
|
||||||
assert fingerprints(dst) == [source_fingerprints[i] for i in kept]
|
|
||||||
|
|
||||||
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF with document info
|
|
||||||
WHEN:
|
|
||||||
- A page is removed
|
|
||||||
THEN:
|
|
||||||
- The document info is still present in the output
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, [1])
|
|
||||||
|
|
||||||
assert "/Creator" in docinfo_keys(dst)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"bad_page",
|
|
||||||
[
|
|
||||||
pytest.param(0, id="zero"),
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_rejects_pages_below_one(self, tmp_path: Path, bad_page: int) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Pages are removed and one of them is below 1
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
- No output file is written
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="start at 1"):
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, [1, bad_page])
|
|
||||||
|
|
||||||
assert not dst.exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPdfs:
|
|
||||||
def test_selects_and_orders_pages(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- One output is built from pages 3 then 1
|
|
||||||
THEN:
|
|
||||||
- The output has those pages in that order
|
|
||||||
- Its path is returned
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
written = pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(3), PageSpec(1)], constant(dst))],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert written == [dst]
|
|
||||||
assert fingerprints(dst) == [source_fingerprints[2], source_fingerprints[0]]
|
|
||||||
|
|
||||||
def test_rotates_only_the_requested_pages(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- One output is built with a different rotation on each page
|
|
||||||
THEN:
|
|
||||||
- Each output page has exactly the rotation requested for it
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1), PageSpec(2, 90), PageSpec(3, 180)], constant(dst))],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rotations(dst) == [0, 90, 180]
|
|
||||||
|
|
||||||
def test_writes_one_file_per_output_in_order(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A twelve page PDF
|
|
||||||
WHEN:
|
|
||||||
- Two outputs are built from different page ranges
|
|
||||||
THEN:
|
|
||||||
- Two files are written and returned in output order
|
|
||||||
- Each holds exactly its own pages
|
|
||||||
"""
|
|
||||||
first = tmp_path / "first.pdf"
|
|
||||||
second = tmp_path / "second.pdf"
|
|
||||||
source = fingerprints(TWELVE_PAGES)
|
|
||||||
|
|
||||||
written = pdf_ops.build_pdfs(
|
|
||||||
TWELVE_PAGES,
|
|
||||||
[
|
|
||||||
([PageSpec(p) for p in (1, 2, 3)], constant(first)),
|
|
||||||
([PageSpec(p) for p in range(4, 13)], constant(second)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert written == [first, second]
|
|
||||||
assert fingerprints(first) == source[:3]
|
|
||||||
assert fingerprints(second) == source[3:]
|
|
||||||
|
|
||||||
def test_empty_page_list_writes_a_zero_page_file(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- An output with no pages is built
|
|
||||||
THEN:
|
|
||||||
- A PDF with zero pages is written
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.build_pdfs(THREE_PAGES, [([], constant(dst))])
|
|
||||||
|
|
||||||
assert fingerprints(dst) == []
|
|
||||||
|
|
||||||
def test_destination_is_not_requested_when_a_page_is_out_of_range(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Several outputs are built
|
|
||||||
- A later output refers to a page past the end
|
|
||||||
THEN:
|
|
||||||
- IndexError is raised
|
|
||||||
- No destination was requested for any output, including earlier valid ones
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(IndexError):
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1)], make_dst), ([PageSpec(99)], make_dst)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"bad_page",
|
|
||||||
[
|
|
||||||
pytest.param(0, id="zero"),
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_rejects_pages_below_one_before_opening_anything(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
bad_page: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Several outputs are built and a later one has a page below 1
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
- No destination was requested for any output
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="start at 1"):
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1)], make_dst), ([PageSpec(bad_page)], make_dst)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePageOperations:
|
|
||||||
def test_returns_the_output_count(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that all target the default output
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- One output document is reported
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1}, {"page": 2}, {"page": 3}]
|
|
||||||
|
|
||||||
assert pdf_ops.validate_page_operations(operations, single_output=True) == 1
|
|
||||||
|
|
||||||
def test_gap_in_output_indices_counts_up_to_the_highest(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that target outputs 0 and 2 but never 1
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- Three output documents are reported
|
|
||||||
"""
|
|
||||||
operations = [
|
|
||||||
{"page": 1, "doc": 0},
|
|
||||||
{"page": 2, "doc": 2},
|
|
||||||
{"page": 3, "doc": 0},
|
|
||||||
]
|
|
||||||
|
|
||||||
count = pdf_ops.validate_page_operations(operations, single_output=False)
|
|
||||||
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
def test_empty_operations_are_rejected(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No operations
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
with pytest.raises(ValueError, match="index is out of bounds"):
|
|
||||||
pdf_ops.validate_page_operations([], single_output=False)
|
|
||||||
|
|
||||||
def test_multiple_outputs_rejected_when_single_output_required(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that target two outputs
|
|
||||||
WHEN:
|
|
||||||
- They are validated with a single output required
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Multiple output documents"):
|
|
||||||
pdf_ops.validate_page_operations(operations, single_output=True)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"doc",
|
|
||||||
[
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
pytest.param(2, id="equal-to-operation-count"),
|
|
||||||
pytest.param(2**32, id="huge"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_output_index_out_of_bounds(self, doc: int) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two operations, one with an output index that is out of bounds
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": doc}]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="index is out of bounds"):
|
|
||||||
pdf_ops.validate_page_operations(operations, single_output=False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDecrypt:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("path", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(ENCRYPTED, True, id="password-required"),
|
|
||||||
pytest.param(SIGNED, True, id="opens-without-password-but-encrypted"),
|
|
||||||
pytest.param(THREE_PAGES, False, id="not-encrypted"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_needs_decrypt(self, path: Path, *, expected: bool) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF that is encrypted, or encrypted but openable, or plain
|
|
||||||
WHEN:
|
|
||||||
- needs_decrypt is asked about it
|
|
||||||
THEN:
|
|
||||||
- Only the unencrypted PDF reports False
|
|
||||||
"""
|
|
||||||
assert pdf_ops.needs_decrypt(path) is expected
|
|
||||||
|
|
||||||
def test_decrypt_writes_an_unencrypted_copy(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A password protected PDF
|
|
||||||
WHEN:
|
|
||||||
- It is decrypted with the correct password
|
|
||||||
THEN:
|
|
||||||
- The path from make_dst is returned
|
|
||||||
- The written copy no longer needs decrypting
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
result = pdf_ops.decrypt_pdf(ENCRYPTED, constant(dst), "test")
|
|
||||||
|
|
||||||
assert result == dst
|
|
||||||
assert pdf_ops.needs_decrypt(dst) is False
|
|
||||||
|
|
||||||
def test_wrong_password_raises_and_never_requests_a_destination(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A password protected PDF
|
|
||||||
WHEN:
|
|
||||||
- It is decrypted with the wrong password
|
|
||||||
THEN:
|
|
||||||
- PasswordError is raised
|
|
||||||
- No destination was requested
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(pikepdf.PasswordError):
|
|
||||||
pdf_ops.decrypt_pdf(ENCRYPTED, make_dst, "wrong")
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestPdfMerger:
|
|
||||||
def test_pages_are_appended_in_the_order_added(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reordered PDF and the original three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Both are added to a merger in that order and saved
|
|
||||||
THEN:
|
|
||||||
- The output holds all pages in the order they were added
|
|
||||||
"""
|
|
||||||
reordered = tmp_path / "reordered.pdf"
|
|
||||||
merged = tmp_path / "merged.pdf"
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(3), PageSpec(1)], constant(reordered))],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
merger.add(reordered)
|
|
||||||
merger.add(THREE_PAGES)
|
|
||||||
merger.save(merged)
|
|
||||||
|
|
||||||
assert fingerprints(merged) == [
|
|
||||||
source_fingerprints[2],
|
|
||||||
source_fingerprints[0],
|
|
||||||
*source_fingerprints,
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_output_version_is_at_least_the_highest_source_version(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two PDFs with different PDF versions
|
|
||||||
WHEN:
|
|
||||||
- Both are added to a merger and saved
|
|
||||||
THEN:
|
|
||||||
- The output version is at least the highest source version
|
|
||||||
"""
|
|
||||||
merged = tmp_path / "merged.pdf"
|
|
||||||
with pikepdf.open(TWELVE_PAGES) as pdf:
|
|
||||||
source_versions = [pdf.pdf_version]
|
|
||||||
with pikepdf.open(THREE_PAGES) as pdf:
|
|
||||||
source_versions.append(pdf.pdf_version)
|
|
||||||
|
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
merger.add(TWELVE_PAGES)
|
|
||||||
merger.add(THREE_PAGES)
|
|
||||||
merger.save(merged)
|
|
||||||
|
|
||||||
with pikepdf.open(merged) as pdf:
|
|
||||||
assert pdf.pdf_version >= max(source_versions)
|
|
||||||
@@ -7,6 +7,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -225,6 +226,25 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||||
|
|
||||||
|
@override_settings(LOGIN_URL="/paperless/accounts/login/")
|
||||||
|
def test_unknown_share_link_redirects_under_subpath(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Paperless is served under a subpath
|
||||||
|
WHEN:
|
||||||
|
- An unknown public share link is requested
|
||||||
|
THEN:
|
||||||
|
- The redirect to the login page keeps the subpath
|
||||||
|
"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/share/unknownsharelink/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Location"],
|
||||||
|
"/paperless/accounts/login/?sharelink_notfound=1",
|
||||||
|
)
|
||||||
|
|
||||||
def test_share_link_missing_file_redirects(self) -> None:
|
def test_share_link_missing_file_redirects(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -6176,6 +6176,50 @@ class TestApplyAISuggestionsWorkflowAction(
|
|||||||
|
|
||||||
self.assertEqual(self.doc.tags.count(), 0)
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
|
||||||
|
def test_unowned_document_does_not_get_private_objects_by_id(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An unowned document
|
||||||
|
- AI suggestions selecting another user's private tag and
|
||||||
|
correspondent by ID, plus an unowned tag
|
||||||
|
WHEN:
|
||||||
|
- The suggestions are applied
|
||||||
|
THEN:
|
||||||
|
- Only the unowned tag is assigned; an unowned document must not
|
||||||
|
be treated as unrestricted
|
||||||
|
"""
|
||||||
|
self.doc.owner = None
|
||||||
|
self.doc.save(update_fields=["owner"])
|
||||||
|
other = User.objects.create(username="someone-else")
|
||||||
|
private_tag = Tag.objects.create(name="Private Tag", owner=other)
|
||||||
|
public_tag = Tag.objects.create(name="Public Tag", owner=None)
|
||||||
|
private_correspondent = Correspondent.objects.create(
|
||||||
|
name="Private Correspondent",
|
||||||
|
owner=other,
|
||||||
|
)
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[
|
||||||
|
WorkflowAction.AISuggestionField.TAGS,
|
||||||
|
WorkflowAction.AISuggestionField.CORRESPONDENT,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
suggestions: ClassificationSuggestions = {
|
||||||
|
**SUGGESTIONS,
|
||||||
|
"tags": {
|
||||||
|
"existing_ids": [private_tag.pk, public_tag.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"correspondents": {
|
||||||
|
"existing_ids": [private_correspondent.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.apply(action, suggestions)
|
||||||
|
|
||||||
|
self.assertEqual(list(self.doc.tags.all()), [public_tag])
|
||||||
|
self.assertIsNone(self.doc.correspondent)
|
||||||
|
|
||||||
def test_unparsable_dates_are_skipped(self) -> None:
|
def test_unparsable_dates_are_skipped(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
+46
-27
@@ -289,6 +289,7 @@ logger = logging.getLogger("paperless.api")
|
|||||||
# clauses efficiently, so this threshold mainly protects SQLite users.
|
# clauses efficiently, so this threshold mainly protects SQLite users.
|
||||||
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
||||||
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
||||||
|
_TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"})
|
||||||
|
|
||||||
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
||||||
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
||||||
@@ -308,6 +309,18 @@ _TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
|||||||
_MAX_QUERY_LENGTH: Final[int] = 4096
|
_MAX_QUERY_LENGTH: Final[int] = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tantivy_search_params(active: list[str]) -> None:
|
||||||
|
# may also have a filter query alongside a simple search e.g. relative dates
|
||||||
|
if len(active) > 1 and set(active) not in _TANTIVY_COMBINABLE_SEARCH_PARAMS:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Specify only one of text, title_search, query, or more_like_id.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_tantivy_query_and_mode(params):
|
def _get_tantivy_query_and_mode(params):
|
||||||
from documents.search import QueryTooLongError
|
from documents.search import QueryTooLongError
|
||||||
from documents.search import SearchMode
|
from documents.search import SearchMode
|
||||||
@@ -321,9 +334,15 @@ def _get_tantivy_query_and_mode(params):
|
|||||||
else:
|
else:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
if len(raw) > _MAX_QUERY_LENGTH:
|
filter_query = (
|
||||||
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
str(params["query"])
|
||||||
return raw, mode
|
if mode is not SearchMode.QUERY and "query" in params
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
for value in (raw, filter_query):
|
||||||
|
if value is not None and len(value) > _MAX_QUERY_LENGTH:
|
||||||
|
raise QueryTooLongError(len(value), _MAX_QUERY_LENGTH)
|
||||||
|
return raw, mode, filter_query
|
||||||
|
|
||||||
|
|
||||||
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
@@ -2566,15 +2585,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
|
|
||||||
def parse_search_params() -> SearchParams:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
active = self._get_active_search_params(request)
|
_validate_tantivy_search_params(
|
||||||
if len(active) > 1:
|
self._get_active_search_params(request),
|
||||||
raise ValidationError(
|
)
|
||||||
{
|
|
||||||
"detail": _(
|
|
||||||
"Specify only one of text, title_search, query, or more_like_id.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ordering_param = request.query_params.get("ordering", "")
|
ordering_param = request.query_params.get("ordering", "")
|
||||||
sort_reverse = ordering_param.startswith("-")
|
sort_reverse = ordering_param.startswith("-")
|
||||||
@@ -2636,7 +2649,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
filtered_qs: QuerySet[Document],
|
filtered_qs: QuerySet[Document],
|
||||||
) -> SearchResultPage:
|
) -> SearchResultPage:
|
||||||
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
||||||
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params)
|
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
||||||
|
request.query_params,
|
||||||
|
)
|
||||||
|
|
||||||
# "score" is not a real Tantivy sort field — it means relevance order,
|
# "score" is not a real Tantivy sort field — it means relevance order,
|
||||||
# which is Tantivy's default when no sort field is specified.
|
# which is Tantivy's default when no sort field is specified.
|
||||||
@@ -2649,6 +2664,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
),
|
),
|
||||||
sort_reverse=sort_reverse,
|
sort_reverse=sort_reverse,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
filter_query=filter_query,
|
||||||
)
|
)
|
||||||
ordered_ids = intersect_and_order(
|
ordered_ids = intersect_and_order(
|
||||||
all_ids,
|
all_ids,
|
||||||
@@ -2883,14 +2899,7 @@ class DocumentSelectionMixin:
|
|||||||
]
|
]
|
||||||
if not search_filters:
|
if not search_filters:
|
||||||
return None
|
return None
|
||||||
if len(search_filters) > 1:
|
_validate_tantivy_search_params(search_filters)
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"detail": _(
|
|
||||||
"Specify only one of text, title_search, query, or more_like_id.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
from documents.search import SearchQueryError
|
from documents.search import SearchQueryError
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
@@ -2909,11 +2918,14 @@ class DocumentSelectionMixin:
|
|||||||
user=search_user,
|
user=search_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
||||||
|
filters,
|
||||||
|
)
|
||||||
search_ids = backend.search_ids(
|
search_ids = backend.search_ids(
|
||||||
query_str,
|
query_str,
|
||||||
user=search_user,
|
user=search_user,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
filter_query=filter_query,
|
||||||
)
|
)
|
||||||
except SearchQueryError as e:
|
except SearchQueryError as e:
|
||||||
# Same user-fixable-query mapping as the search list endpoint:
|
# Same user-fixable-query mapping as the search list endpoint:
|
||||||
@@ -3920,7 +3932,12 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
"request": request,
|
"request": request,
|
||||||
}
|
}
|
||||||
|
|
||||||
docs_serializer = DocumentSerializer(docs, many=True, context=context)
|
docs_serializer = DocumentSerializer(
|
||||||
|
docs,
|
||||||
|
many=True,
|
||||||
|
context=context,
|
||||||
|
fields=("id", "title", "created"),
|
||||||
|
)
|
||||||
saved_views_serializer = SavedViewSerializer(
|
saved_views_serializer = SavedViewSerializer(
|
||||||
saved_views,
|
saved_views,
|
||||||
many=True,
|
many=True,
|
||||||
@@ -4899,7 +4916,7 @@ class SharedLinkView(View):
|
|||||||
share_link.expiration is not None
|
share_link.expiration is not None
|
||||||
and share_link.expiration < timezone.now()
|
and share_link.expiration < timezone.now()
|
||||||
):
|
):
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
|
||||||
try:
|
try:
|
||||||
return serve_file(
|
return serve_file(
|
||||||
doc=share_link.document,
|
doc=share_link.document,
|
||||||
@@ -4908,14 +4925,16 @@ class SharedLinkView(View):
|
|||||||
disposition="inline",
|
disposition="inline",
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
return HttpResponseRedirect(
|
||||||
|
f"{settings.LOGIN_URL}?sharelink_notfound=1",
|
||||||
|
)
|
||||||
|
|
||||||
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
||||||
if bundle is None:
|
if bundle is None:
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_notfound=1")
|
||||||
|
|
||||||
if bundle.expiration is not None and bundle.expiration < timezone.now():
|
if bundle.expiration is not None and bundle.expiration < timezone.now():
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
|
||||||
|
|
||||||
if bundle.status in {
|
if bundle.status in {
|
||||||
ShareLinkBundle.Status.PENDING,
|
ShareLinkBundle.Status.PENDING,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user