mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 06:48:16 +00:00
Compare 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 |
No files matched your search
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.2"
|
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\
|
||||||
"""
|
"""
|
||||||
|
|||||||
+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.2",
|
"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
|
||||||
|
|||||||
+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.2',
|
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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -2137,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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
+12
-5
@@ -3932,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,
|
||||||
@@ -4911,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,
|
||||||
@@ -4920,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
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
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user