mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 06:48:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b83e734aef | ||
|
|
8d3902454e | ||
|
|
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 |
No files matched your search
@@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.3.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>10 changes</summary>
|
||||
|
||||
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||
|
||||
</details>
|
||||
|
||||
## 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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.3.1"
|
||||
version = "3.3.3"
|
||||
description = """\
|
||||
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-sidecar>=2026.7.1,<2026.10",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"filelock>=3.32,<4.1",
|
||||
"flower>=2.0.1,<2.3",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
|
||||
@@ -15,6 +15,8 @@ module.exports = {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
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",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -102,25 +102,34 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<ngb-pagination
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="totalTasks()"
|
||||
[page]="page"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
size="sm"
|
||||
aria-label="Tasks pagination"
|
||||
(pageChange)="setPage($event)">
|
||||
</ngb-pagination>
|
||||
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
|
||||
<ngb-pagination
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="totalTasks()"
|
||||
[page]="page"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
size="sm"
|
||||
aria-label="Tasks pagination"
|
||||
(pageChange)="setPage($event)">
|
||||
</ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||
<div class="small text-muted">
|
||||
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
||||
</div>
|
||||
<div class="section-header mb-2">
|
||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||
@let count = sectionCount(section);
|
||||
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
|
||||
<div class="small text-muted">
|
||||
@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>
|
||||
|
||||
@@ -169,10 +178,9 @@
|
||||
<div>{{ taskDisplayName(task) }}</div>
|
||||
<div class="small text-muted">
|
||||
@if (taskShowsSeparateTypeLabel(task)) {
|
||||
<span>{{ task.task_type_display }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
<span class="me-2">{{ task.task_type_display }}</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>
|
||||
</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 {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.section-header .show-all {
|
||||
font-size: inherit;
|
||||
}
|
||||
@@ -223,16 +223,6 @@ describe('TasksComponent', () => {
|
||||
)
|
||||
.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
|
||||
.expectOne(
|
||||
(req) =>
|
||||
@@ -241,8 +231,30 @@ describe('TasksComponent', () => {
|
||||
!req.params.has('status')
|
||||
)
|
||||
.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', () => {
|
||||
expect(component.selectedSection).toBe(TaskSection.All)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
@@ -338,6 +350,7 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should render pagination controls next to the task filter', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
fixture.detectChanges()
|
||||
|
||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||
@@ -348,6 +361,51 @@ describe('TasksComponent', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
).toHaveLength(3)
|
||||
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
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('trigger_source') ===
|
||||
PaperlessTaskTriggerSource.System
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should apply text filters to the server-side task query', () => {
|
||||
component.filterText = 'invoice'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('name') === 'invoice'
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[0]] })
|
||||
).toHaveLength(3)
|
||||
|
||||
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('result') === 'invoice'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should load a different task page when pagination changes', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
component.setPage(2)
|
||||
|
||||
const pageTwoTasks = {
|
||||
@@ -440,6 +499,7 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should not replace section counts with current-page counts', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
component.setPage(2)
|
||||
|
||||
httpTestingController
|
||||
@@ -455,11 +515,21 @@ describe('TasksComponent', () => {
|
||||
results: [tasks[0]],
|
||||
})
|
||||
|
||||
expect(component.sectionCount(TaskSection.All)).toBe(7)
|
||||
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
||||
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
||||
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', () => {
|
||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||
PaperlessTaskType.TrainClassifier
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
first,
|
||||
forkJoin,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
timer,
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
@@ -169,6 +173,7 @@ export class TasksComponent
|
||||
public expandedTask: number
|
||||
public autoRefreshEnabled: boolean = true
|
||||
public readonly pageSize = 25
|
||||
public readonly overviewSize = 10
|
||||
public page: number = 1
|
||||
readonly totalTasks = signal(0)
|
||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
||||
@@ -484,13 +489,6 @@ export class TasksComponent
|
||||
return this.sectionCounts()[section]
|
||||
}
|
||||
|
||||
private setSectionCount(section: TaskSection, count: number) {
|
||||
this.sectionCounts.update((counts) => ({
|
||||
...counts,
|
||||
[section]: count,
|
||||
}))
|
||||
}
|
||||
|
||||
sectionShowsResults(section: TaskSection): boolean {
|
||||
return section !== TaskSection.InProgress
|
||||
}
|
||||
@@ -737,21 +735,12 @@ export class TasksComponent
|
||||
this.reloadSectionCounts()
|
||||
|
||||
this.loading.set(true)
|
||||
this.tasksService
|
||||
.list(
|
||||
this.page,
|
||||
this.pageSize,
|
||||
this.getParamsForSection(this.selectedSection)
|
||||
)
|
||||
this.listTasks()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.pagedTasks.set(result.results)
|
||||
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)
|
||||
if (
|
||||
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()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
|
||||
@@ -116,8 +116,9 @@ export class UsersAndGroupsComponent
|
||||
$localize`Password has been changed, you will be logged out momentarily.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
const basePath = new URL(document.baseURI).pathname
|
||||
setLocationHref(
|
||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||
)
|
||||
}, 2500)
|
||||
} else {
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
}
|
||||
|
||||
.search-container {
|
||||
min-width: 0;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
@@ -499,6 +500,11 @@ main {
|
||||
|
||||
// true center the search with equal flex widths
|
||||
@media (min-width: 768px) {
|
||||
.search-container {
|
||||
// Prevent input padding and the conditional search button from resizing
|
||||
flex-basis: 55rem;
|
||||
}
|
||||
|
||||
.navbar-brand-container,
|
||||
.navbar > ul {
|
||||
display: flex; // so the brand link stays as wide as its contents
|
||||
|
||||
@@ -70,6 +70,17 @@ form {
|
||||
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 {
|
||||
&:has(button:focus) {
|
||||
background-color: var(--pngx-bg-darker);
|
||||
|
||||
+32
@@ -218,6 +218,38 @@ describe('ProfileEditDialogComponent', () => {
|
||||
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', () => {
|
||||
jest.useFakeTimers()
|
||||
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.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
const basePath = new URL(document.baseURI).pathname
|
||||
setLocationHref(
|
||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||
)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
+33
-35
@@ -1,6 +1,6 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<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()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else if (noSuggestions) {
|
||||
@@ -18,46 +18,44 @@
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
<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">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
<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">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</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>
|
||||
@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_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>
|
||||
@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>
|
||||
@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_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>
|
||||
@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>
|
||||
@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 (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>
|
||||
}
|
||||
@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>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
|
||||
+31
@@ -251,6 +251,37 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
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', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
|
||||
+1
-5
@@ -53,11 +53,7 @@ export class SuggestionsDropdownComponent {
|
||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
public clickSuggest(): void {
|
||||
if (
|
||||
this.disabled() ||
|
||||
this.loading() ||
|
||||
(this.suggestions() && !this.aiEnabled())
|
||||
) {
|
||||
if (this.disabled() || this.loading()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -25,6 +25,8 @@
|
||||
@if (displayFields().includes(field)) {
|
||||
<th
|
||||
scope="col"
|
||||
class="text-truncate"
|
||||
[title]="getColumnTitle(field)"
|
||||
[ngClass]="{
|
||||
'd-none d-md-table-cell': i > 1,
|
||||
'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">
|
||||
<label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
|
||||
</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">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
@@ -19,7 +19,9 @@
|
||||
</select>
|
||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||
</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>
|
||||
|
||||
@@ -70,7 +72,7 @@
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
|
||||
+11
-9
@@ -93,15 +93,17 @@
|
||||
[disabled]="selectedMailIds().size === 0"
|
||||
(confirm)="deleteSelected()">
|
||||
</pngx-confirm-button>
|
||||
<div class="ms-auto">
|
||||
<ngb-pagination
|
||||
[collectionSize]="processedMails().length"
|
||||
[(page)]="page"
|
||||
[pageSize]="50"
|
||||
[maxSize]="5"
|
||||
(pageChange)="loadProcessedMails()">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
@if (processedMails().length > 50) {
|
||||
<div class="ms-auto">
|
||||
<ngb-pagination
|
||||
[collectionSize]="processedMails().length"
|
||||
[(page)]="page"
|
||||
[pageSize]="50"
|
||||
[maxSize]="5"
|
||||
(pageChange)="loadProcessedMails()">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.3.1',
|
||||
version: '3.3.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : '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,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
@@ -293,6 +294,7 @@ const icons = {
|
||||
braces,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
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.
|
||||
"""
|
||||
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_class = int(classifier.classes_[best_idx])
|
||||
|
||||
|
||||
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.change_users = list(
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["change_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.custom_fields = {
|
||||
|
||||
@@ -3,6 +3,7 @@ from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
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 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(
|
||||
queryset: "QuerySet[Any]",
|
||||
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
counter=filename_counter,
|
||||
)
|
||||
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
|
||||
# uniqueness must hold without the original's extension
|
||||
@@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||
|
||||
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:
|
||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||
thumbnail_arc = thumbnail_name.as_posix()
|
||||
@@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
thumbnail_arc = None
|
||||
|
||||
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:
|
||||
archive_name = Path("archive") / archive_name
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
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]
|
||||
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,
|
||||
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def archive_file(self):
|
||||
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.
|
||||
|
||||
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
|
||||
context_document = (
|
||||
self.root_document if self.root_document_id is not None else self
|
||||
)
|
||||
result = str(context_document)
|
||||
|
||||
if counter:
|
||||
result += f"_{counter:02}"
|
||||
extension = ".pdf" if archive else self.file_type
|
||||
tail = f"_{counter:02}{extension}" if counter else extension
|
||||
|
||||
if suffix:
|
||||
result += suffix
|
||||
head_bytes = max(0, max_bytes - len(tail.encode()))
|
||||
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||
|
||||
if archive:
|
||||
result += ".pdf"
|
||||
else:
|
||||
result += self.file_type
|
||||
|
||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
||||
return pathvalidate.sanitize_filename(
|
||||
head + tail,
|
||||
replacement_text="-",
|
||||
max_len=max_bytes,
|
||||
)
|
||||
|
||||
@property
|
||||
def file_type(self):
|
||||
|
||||
@@ -21,6 +21,7 @@ from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
@@ -510,6 +511,28 @@ def restrict_queryset_to_visible(
|
||||
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(
|
||||
user: User | None,
|
||||
*,
|
||||
@@ -648,6 +671,41 @@ def annotate_document_count_for_related_queryset(
|
||||
)
|
||||
|
||||
|
||||
def get_objects_for_user_owner_aware(
|
||||
user: User | None,
|
||||
perms: str | list[str],
|
||||
Model: Any,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> QuerySet[Any]:
|
||||
"""
|
||||
Returns objects the user owns, are unowned, or has explicit perms.
|
||||
When include_deleted is True, soft-deleted items are also included.
|
||||
|
||||
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||
Most queryset-filtering call sites have migrated onto
|
||||
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||
is kept because production callers still remain. Several callers remain
|
||||
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||
grep for this function name before removing it.
|
||||
"""
|
||||
manager = (
|
||||
Model.global_objects
|
||||
if include_deleted and hasattr(Model, "global_objects")
|
||||
else Model.objects
|
||||
)
|
||||
|
||||
objects_owned = manager.filter(owner=user)
|
||||
objects_unowned = manager.filter(owner__isnull=True)
|
||||
objects_with_perms = get_objects_for_user(
|
||||
user=user,
|
||||
perms=perms,
|
||||
klass=manager.all(),
|
||||
accept_global_perms=False,
|
||||
)
|
||||
return objects_owned | objects_unowned | objects_with_perms
|
||||
|
||||
|
||||
def has_perms_owner_aware(user, perms, obj):
|
||||
"""
|
||||
Legacy slow path (guardian-backed) single-object permission check.
|
||||
|
||||
@@ -52,7 +52,7 @@ from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
@@ -81,11 +81,15 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||
inbox_tags = restrict_queryset_to_visible(
|
||||
Tag.objects.filter(is_inbox_tag=True),
|
||||
document.owner,
|
||||
"view_tag",
|
||||
)
|
||||
if document.owner is not None:
|
||||
tags = get_objects_for_user_owner_aware(
|
||||
document.owner,
|
||||
"documents.view_tag",
|
||||
Tag,
|
||||
)
|
||||
else:
|
||||
tags = Tag.objects.all()
|
||||
inbox_tags = tags.filter(is_inbox_tag=True)
|
||||
document.add_nested_tags(inbox_tags)
|
||||
|
||||
|
||||
|
||||
@@ -2137,29 +2137,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
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:
|
||||
user1 = UserFactory(username="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 pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
from sklearn.neural_network import MLPClassifier
|
||||
|
||||
from documents.classifier import ClassifierModelCorruptError
|
||||
from documents.classifier import DocumentClassifier
|
||||
@@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None:
|
||||
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:
|
||||
@pytest.fixture
|
||||
def samples(self) -> Path:
|
||||
|
||||
@@ -34,7 +34,7 @@ from documents.tests.utils import GetConsumerMixin
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
from paperless_testing.permissions import grant_object
|
||||
@@ -516,65 +516,6 @@ class TestConsumer(
|
||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsScopedToOwner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags that are owned by the document owner, unowned, owned by
|
||||
another user but shared with the document owner, and owned by
|
||||
another user and not shared
|
||||
- A non-inbox tag owned by the document owner
|
||||
WHEN:
|
||||
- A document is consumed with that owner
|
||||
THEN:
|
||||
- The document gets the owned, unowned and shared inbox tags only
|
||||
"""
|
||||
owner = UserFactory()
|
||||
other = UserFactory()
|
||||
owned = TagFactory(is_inbox_tag=True, owner=owner)
|
||||
unowned = TagFactory(is_inbox_tag=True, owner=None)
|
||||
shared = TagFactory(is_inbox_tag=True, owner=other)
|
||||
grant_object(owner, shared, "view_tag")
|
||||
TagFactory(is_inbox_tag=True, owner=other)
|
||||
TagFactory(is_inbox_tag=False, owner=owner)
|
||||
|
||||
with self.get_consumer(
|
||||
self.get_test_file(),
|
||||
DocumentMetadataOverrides(owner_id=owner.pk),
|
||||
) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), [owned, unowned, shared])
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsUnownedDocument(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags owned by two different users, an unowned inbox tag and
|
||||
a non-inbox tag
|
||||
WHEN:
|
||||
- A document is consumed with no owner
|
||||
THEN:
|
||||
- The document gets every inbox tag
|
||||
"""
|
||||
inbox_tags = [
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=None),
|
||||
]
|
||||
TagFactory(is_inbox_tag=False)
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), inbox_tags)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testNotAFile(self) -> None:
|
||||
with self.assertRaisesMessage(ConsumerError, "File not found"):
|
||||
with self.get_consumer(Path("non-existing-file")) as consumer:
|
||||
@@ -1744,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
|
||||
self.assertEqual(base.actor_id, 2)
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -423,35 +423,3 @@ class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||
assert result["content"] == "own-content"
|
||||
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 unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from faker import Faker
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.tasks import empty_trash
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
|
||||
class TestDocument(TestCase):
|
||||
@@ -257,3 +260,72 @@ def test_suggestion_content() -> None:
|
||||
)
|
||||
doc.content = content_over_limit
|
||||
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_THUMBNAIL_NAME
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
@@ -1402,6 +1403,45 @@ class TestCryptExportImport(
|
||||
social_token = SocialToken.objects.first()
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -1537,3 +1577,70 @@ class TestExportDerivedFileNameCollisions:
|
||||
assert (
|
||||
target / record[EXPORTER_ARCHIVE_NAME]
|
||||
).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.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
@@ -225,6 +226,25 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -6176,6 +6176,50 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
+43
-38
@@ -175,12 +175,12 @@ from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
@@ -3836,51 +3836,49 @@ class GlobalSearchView(PassUserMixin):
|
||||
if doc_id in docs_by_id
|
||||
][:OBJECT_LIMIT]
|
||||
saved_views = (
|
||||
restrict_queryset_to_visible(
|
||||
SavedView.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_savedview",
|
||||
)
|
||||
SavedView,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_savedview")
|
||||
else []
|
||||
)
|
||||
saved_views = saved_views[:OBJECT_LIMIT]
|
||||
tags = (
|
||||
restrict_queryset_to_visible(
|
||||
Tag.objects.filter(name__icontains=query),
|
||||
request.user,
|
||||
"view_tag",
|
||||
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
|
||||
name__icontains=query,
|
||||
)
|
||||
if request.user.has_perm("documents.view_tag")
|
||||
else []
|
||||
)
|
||||
tags = tags[:OBJECT_LIMIT]
|
||||
correspondents = (
|
||||
restrict_queryset_to_visible(
|
||||
Correspondent.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_correspondent",
|
||||
)
|
||||
Correspondent,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_correspondent")
|
||||
else []
|
||||
)
|
||||
correspondents = correspondents[:OBJECT_LIMIT]
|
||||
document_types = (
|
||||
restrict_queryset_to_visible(
|
||||
DocumentType.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_documenttype",
|
||||
)
|
||||
DocumentType,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_documenttype")
|
||||
else []
|
||||
)
|
||||
document_types = document_types[:OBJECT_LIMIT]
|
||||
storage_paths = (
|
||||
restrict_queryset_to_visible(
|
||||
StoragePath.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_storagepath",
|
||||
)
|
||||
StoragePath,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_storagepath")
|
||||
else []
|
||||
)
|
||||
@@ -3898,21 +3896,21 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
groups = groups[:OBJECT_LIMIT]
|
||||
mail_rules = (
|
||||
restrict_queryset_to_visible(
|
||||
MailRule.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailrule",
|
||||
)
|
||||
MailRule,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||
else []
|
||||
)
|
||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||
mail_accounts = (
|
||||
restrict_queryset_to_visible(
|
||||
MailAccount.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailaccount",
|
||||
)
|
||||
MailAccount,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||
else []
|
||||
)
|
||||
@@ -3934,7 +3932,12 @@ class GlobalSearchView(PassUserMixin):
|
||||
"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,
|
||||
many=True,
|
||||
@@ -4028,33 +4031,33 @@ class StatisticsView(GenericAPIView[Any]):
|
||||
tags = (
|
||||
Tag.objects.all()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
||||
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||
).only("id", "is_inbox_tag")
|
||||
correspondent_count = (
|
||||
Correspondent.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
Correspondent.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_correspondent",
|
||||
"documents.view_correspondent",
|
||||
Correspondent,
|
||||
).count()
|
||||
)
|
||||
document_type_count = (
|
||||
DocumentType.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
DocumentType.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_documenttype",
|
||||
"documents.view_documenttype",
|
||||
DocumentType,
|
||||
).count()
|
||||
)
|
||||
storage_path_count = (
|
||||
StoragePath.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
StoragePath.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_storagepath",
|
||||
"documents.view_storagepath",
|
||||
StoragePath,
|
||||
).count()
|
||||
)
|
||||
|
||||
@@ -4913,7 +4916,7 @@ class SharedLinkView(View):
|
||||
share_link.expiration is not None
|
||||
and share_link.expiration < timezone.now()
|
||||
):
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
|
||||
try:
|
||||
return serve_file(
|
||||
doc=share_link.document,
|
||||
@@ -4922,14 +4925,16 @@ class SharedLinkView(View):
|
||||
disposition="inline",
|
||||
)
|
||||
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()
|
||||
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():
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
|
||||
|
||||
if bundle.status in {
|
||||
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
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user