Compare commits

..
Author SHA1 Message Date
stumpylog 8fd655e848 Chore: drop get_objects_for_user_owner_aware
Drops get_objects_for_user_owner_aware. Callers now use restrict_queryset_to_visible.

The same question was answered two ways, and the guardian path did not deny
deactivated users the way permitted_object_ids does.
2026-10-14 12:59:50 -07:00
145 changed files with 27893 additions and 29087 deletions

No files matched your search

-26
View File
@@ -1,31 +1,5 @@
# 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
### Bug Fixes
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.3.3"
version = "3.3.1"
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,<4.1",
"filelock~=3.32.0",
"flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17",
-2
View File
@@ -15,8 +15,6 @@ 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,
},
],
},
+167 -178
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.3.3",
"version": "3.3.1",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -102,34 +102,25 @@
</button>
}
@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>
}
<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 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 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>
</div>
@@ -178,9 +169,10 @@
<div>{{ taskDisplayName(task) }}</div>
<div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) {
<span class="me-2">{{ task.task_type_display }}</span>
<span>{{ task.task_type_display }}</span>
<span class="mx-1">&bull;</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>
<span>{{ task.trigger_source_display }}</span>
</div>
</td>
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
@@ -72,7 +72,3 @@ pre {
tbody tr:nth-last-child(2) td {
border-bottom: none !important;
}
.section-header .show-all {
font-size: inherit;
}
@@ -223,6 +223,16 @@ 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) =>
@@ -231,30 +241,8 @@ 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()
@@ -350,7 +338,6 @@ 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'))
@@ -361,51 +348,6 @@ 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)
@@ -426,56 +368,55 @@ describe('TasksComponent', () => {
it('should apply task type and trigger source filters to the server-side task query', () => {
component.setTaskType(PaperlessTaskType.SanityCheck)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[6]] })
component.setTriggerSource(PaperlessTaskTriggerSource.System)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
request.params.get('trigger_source') ===
PaperlessTaskTriggerSource.System
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[6]] })
})
it('should apply text filters to the server-side task query', () => {
component.filterText = 'invoice'
jest.advanceTimersByTime(150)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('name') === 'invoice'
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[0]] })
component.setFilterTarget(TaskFilterTargetID.Result)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('result') === 'invoice'
)
).toHaveLength(3)
.flush({ count: 0, results: [] })
})
it('should load a different task page when pagination changes', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
const pageTwoTasks = {
@@ -499,7 +440,6 @@ describe('TasksComponent', () => {
})
it('should not replace section counts with current-page counts', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
httpTestingController
@@ -515,21 +455,11 @@ 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,9 +15,6 @@ import {
distinctUntilChanged,
filter,
first,
forkJoin,
map,
Observable,
Subject,
takeUntil,
timer,
@@ -28,7 +25,6 @@ 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'
@@ -173,7 +169,6 @@ 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>>({
@@ -489,6 +484,13 @@ 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
}
@@ -735,12 +737,21 @@ export class TasksComponent
this.reloadSectionCounts()
this.loading.set(true)
this.listTasks()
this.tasksService
.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
.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 &&
@@ -756,30 +767,4 @@ 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,31 +154,6 @@ 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,9 +116,8 @@ 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}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
} else {
@@ -66,7 +66,6 @@
}
.search-container {
min-width: 0;
padding-left: 1rem;
padding-right: 1rem;
}
@@ -500,11 +499,6 @@ 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,17 +70,6 @@ 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);
@@ -218,38 +218,6 @@ 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')
@@ -210,9 +210,8 @@ 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}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
}
@@ -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()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
<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>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
@@ -18,44 +18,46 @@
}
</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>
@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 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>
<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_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_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_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_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 (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>
}
@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>
</div>
</div>
</div>
}
</div>
@if (aiEnabled()) {
@@ -251,37 +251,6 @@ 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)
@@ -53,7 +53,11 @@ export class SuggestionsDropdownComponent {
addCorrespondent: EventEmitter<string> = new EventEmitter()
public clickSuggest(): void {
if (this.disabled() || this.loading()) {
if (
this.disabled() ||
this.loading() ||
(this.suggestions() && !this.aiEnabled())
) {
return
}
@@ -25,8 +25,6 @@
@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
@@ -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">
<div class="input-group input-group-sm w-auto me-3">
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
@@ -19,9 +19,7 @@
</select>
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
</div>
@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>
}
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
</div>
@@ -72,7 +70,7 @@
}
</div>
}
@if (collectionSize() > pageSize) {
@if (collectionSize() > 20) {
<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>
@@ -93,17 +93,15 @@
[disabled]="selectedMailIds().size === 0"
(confirm)="deleteSelected()">
</pngx-confirm-button>
@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 class="ms-auto">
<ngb-pagination
[collectionSize]="processedMails().length"
[(page)]="page"
[pageSize]="50"
[maxSize]="5"
(pageChange)="loadProcessedMails()">
</ngb-pagination>
</div>
</div>
}
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.3.3',
version: '3.3.1',
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
-2
View File
@@ -41,7 +41,6 @@ import {
bell,
bodyText,
bookmark,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -294,7 +293,6 @@ const icons = {
braces,
bodyText,
bookmark,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
-4
View File
@@ -54,10 +54,6 @@ 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])
-2
View File
@@ -130,14 +130,12 @@ 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,7 +3,6 @@ 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
@@ -71,15 +70,6 @@ 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]",
@@ -522,10 +512,7 @@ class Command(CryptMixin, PaperlessCommand):
counter=filename_counter,
)
else:
base_name = document.get_public_filename(
counter=filename_counter,
max_bytes=MAX_BASE_NAME_BYTES,
)
base_name = document.get_public_filename(counter=filename_counter)
# The thumbnail and archive names are derived from the stem, so
# uniqueness must hold without the original's extension
@@ -554,7 +541,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_SUFFIX)
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
if self.use_folder_prefix:
thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_arc = thumbnail_name.as_posix()
@@ -563,7 +550,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_SUFFIX)
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
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", use_float=True)
yield from ijson.items(f, "item")
except ijson.JSONError as e:
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
+12 -23
View File
@@ -157,8 +157,6 @@ 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,
@@ -486,37 +484,28 @@ 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: bool = False,
counter: int = 0,
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
) -> str:
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> 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)
extension = ".pdf" if archive else self.file_type
tail = f"_{counter:02}{extension}" if counter else extension
if counter:
result += f"_{counter:02}"
head_bytes = max(0, max_bytes - len(tail.encode()))
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
if suffix:
result += suffix
return pathvalidate.sanitize_filename(
head + tail,
replacement_text="-",
max_len=max_bytes,
)
if archive:
result += ".pdf"
else:
result += self.file_type
return pathvalidate.sanitize_filename(result, replacement_text="-")
@property
def file_type(self):
-58
View File
@@ -21,7 +21,6 @@ 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
@@ -511,28 +510,6 @@ 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,
*,
@@ -671,41 +648,6 @@ 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.
+6 -10
View File
@@ -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 get_objects_for_user_owner_aware
from documents.permissions import restrict_queryset_to_visible
from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum
@@ -81,15 +81,11 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
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)
inbox_tags = restrict_queryset_to_visible(
Tag.objects.filter(is_inbox_tag=True),
document.owner,
"view_tag",
)
document.add_nested_tags(inbox_tags)
+23
View File
@@ -2137,6 +2137,29 @@ 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")
-44
View File
@@ -14,7 +14,6 @@ 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
@@ -1037,49 +1036,6 @@ 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:
+60 -27
View File
@@ -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 DocumentFactory
from paperless_testing.factories import TagFactory
from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object
@@ -516,6 +516,65 @@ 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:
@@ -1685,32 +1744,6 @@ 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,3 +423,35 @@ 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,16 +4,13 @@ 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):
@@ -260,72 +257,3 @@ 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,7 +50,6 @@ 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
@@ -1403,45 +1402,6 @@ 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:
@@ -1577,70 +1537,3 @@ 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,7 +7,6 @@ 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
@@ -226,25 +225,6 @@ 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:
-44
View File
@@ -6176,50 +6176,6 @@ 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:
+38 -43
View File
@@ -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,49 +3836,51 @@ class GlobalSearchView(PassUserMixin):
if doc_id in docs_by_id
][:OBJECT_LIMIT]
saved_views = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
SavedView.objects.filter(name__icontains=query),
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 = (
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
name__icontains=query,
restrict_queryset_to_visible(
Tag.objects.filter(name__icontains=query),
request.user,
"view_tag",
)
if request.user.has_perm("documents.view_tag")
else []
)
tags = tags[:OBJECT_LIMIT]
correspondents = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
Correspondent.objects.filter(name__icontains=query),
request.user,
"view_correspondent",
Correspondent,
).filter(name__icontains=query)
)
if request.user.has_perm("documents.view_correspondent")
else []
)
correspondents = correspondents[:OBJECT_LIMIT]
document_types = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
DocumentType.objects.filter(name__icontains=query),
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 = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
StoragePath.objects.filter(name__icontains=query),
request.user,
"view_storagepath",
StoragePath,
).filter(name__icontains=query)
)
if request.user.has_perm("documents.view_storagepath")
else []
)
@@ -3896,21 +3898,21 @@ class GlobalSearchView(PassUserMixin):
)
groups = groups[:OBJECT_LIMIT]
mail_rules = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
MailRule.objects.filter(name__icontains=query),
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 = (
get_objects_for_user_owner_aware(
restrict_queryset_to_visible(
MailAccount.objects.filter(name__icontains=query),
request.user,
"view_mailaccount",
MailAccount,
).filter(name__icontains=query)
)
if request.user.has_perm("paperless_mail.view_mailaccount")
else []
)
@@ -3932,12 +3934,7 @@ class GlobalSearchView(PassUserMixin):
"request": request,
}
docs_serializer = DocumentSerializer(
docs,
many=True,
context=context,
fields=("id", "title", "created"),
)
docs_serializer = DocumentSerializer(docs, many=True, context=context)
saved_views_serializer = SavedViewSerializer(
saved_views,
many=True,
@@ -4031,33 +4028,33 @@ class StatisticsView(GenericAPIView[Any]):
tags = (
Tag.objects.all()
if can_view_global_stats
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
).only("id", "is_inbox_tag")
correspondent_count = (
Correspondent.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
else restrict_queryset_to_visible(
Correspondent.objects.all(),
user,
"documents.view_correspondent",
Correspondent,
"view_correspondent",
).count()
)
document_type_count = (
DocumentType.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
else restrict_queryset_to_visible(
DocumentType.objects.all(),
user,
"documents.view_documenttype",
DocumentType,
"view_documenttype",
).count()
)
storage_path_count = (
StoragePath.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
else restrict_queryset_to_visible(
StoragePath.objects.all(),
user,
"documents.view_storagepath",
StoragePath,
"view_storagepath",
).count()
)
@@ -4916,7 +4913,7 @@ class SharedLinkView(View):
share_link.expiration is not None
and share_link.expiration < timezone.now()
):
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
try:
return serve_file(
doc=share_link.document,
@@ -4925,16 +4922,14 @@ class SharedLinkView(View):
disposition="inline",
)
except FileNotFoundError:
return HttpResponseRedirect(
f"{settings.LOGIN_URL}?sharelink_notfound=1",
)
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None:
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_notfound=1")
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
if bundle.expiration is not None and bundle.expiration < timezone.now():
return HttpResponseRedirect(f"{settings.LOGIN_URL}?sharelink_expired=1")
return HttpResponseRedirect("/accounts/login/?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