mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-19 22:38:13 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31caf01ae5 | ||
|
|
e756f5985f | ||
|
|
ce1dee7549 | ||
|
|
fd187cd09a | ||
|
|
814b208280 | ||
|
|
11c32d352e | ||
|
|
c21deaa452 | ||
|
|
c47766121d | ||
|
|
979bcd3896 | ||
|
|
d450f03d5f | ||
|
|
730c2d8688 | ||
|
|
b1738c85d7 | ||
|
|
a8b835fcd0 | ||
|
|
41498113d3 | ||
|
|
309a4b004d | ||
|
|
961f7fd672 | ||
|
|
de94657e17 | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 | ||
|
|
15e9a7a017 | ||
|
|
04a927c25f | ||
|
|
30a24ee02e | ||
|
|
1943c99b27 | ||
|
|
8b26ecafb6 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 |
No files matched your search
@@ -1,5 +1,31 @@
|
||||
# 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
@@ -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,
|
||||
|
||||
@@ -34,7 +34,6 @@ from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
@@ -446,7 +445,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions: PermissionsSpec,
|
||||
set_permissions: dict,
|
||||
*,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import ParseError
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.base import AlwaysRunPluginMixin
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import NoCleanupPluginMixin
|
||||
@@ -949,7 +948,7 @@ class ConsumerPlugin(
|
||||
or self.metadata.change_users is not None
|
||||
or self.metadata.change_groups is not None
|
||||
):
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": self.metadata.view_users or [],
|
||||
"groups": self.metadata.view_groups or [],
|
||||
@@ -959,7 +958,7 @@ class ConsumerPlugin(
|
||||
"groups": self.metadata.change_groups or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_objects(permissions, Document, [document.pk])
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
|
||||
if self.metadata.custom_fields:
|
||||
for field in CustomField.objects.filter(
|
||||
|
||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import magic
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
|
||||
|
||||
class StoredBarcode(TypedDict):
|
||||
@@ -115,9 +117,6 @@ class DocumentMetadataOverrides:
|
||||
"""
|
||||
Fills in the overrides from a document object
|
||||
"""
|
||||
# documents.models imports this module, so this can't be a top-level import
|
||||
from documents.permissions import get_permission_grantees
|
||||
|
||||
overrides = DocumentMetadataOverrides()
|
||||
overrides.title = doc.title
|
||||
overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None
|
||||
@@ -127,16 +126,40 @@ class DocumentMetadataOverrides:
|
||||
overrides.tag_ids = list(doc.tags.values_list("id", flat=True))
|
||||
overrides.created = doc.created
|
||||
|
||||
grantees = get_permission_grantees(type(doc), [doc.pk])[doc.pk]
|
||||
overrides.view_users = grantees["view"]["users"]
|
||||
overrides.view_groups = grantees["view"]["groups"]
|
||||
overrides.change_users = grantees["change"]["users"]
|
||||
overrides.change_groups = grantees["change"]["groups"]
|
||||
overrides.view_users = list(
|
||||
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 = {
|
||||
custom_field.field.id: custom_field.value
|
||||
for custom_field in doc.custom_fields.all()
|
||||
}
|
||||
|
||||
groups_with_perms = get_groups_with_perms(
|
||||
doc,
|
||||
attach_perms=True,
|
||||
)
|
||||
overrides.view_groups = [
|
||||
group.id
|
||||
for group in groups_with_perms
|
||||
if "view_document" in groups_with_perms[group]
|
||||
]
|
||||
overrides.change_groups = [
|
||||
group.id
|
||||
for group in groups_with_perms
|
||||
if "change_document" in groups_with_perms[group]
|
||||
]
|
||||
|
||||
return overrides
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
+156
-97
@@ -1,10 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
from typing import Literal
|
||||
from typing import TypeAlias
|
||||
from typing import TypedDict
|
||||
from typing import TypeVar
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -26,39 +20,16 @@ from django.db.models.functions import Coalesce
|
||||
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
|
||||
from rest_framework.permissions import DjangoObjectPermissions
|
||||
|
||||
from documents.models import Document
|
||||
from documents.versioning import get_root_document
|
||||
|
||||
PermissionAction = Literal["view", "change"]
|
||||
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
|
||||
|
||||
|
||||
class PermissionGrantees(TypedDict):
|
||||
"""
|
||||
The user and group ids holding one permission action directly on an
|
||||
object. Users who only hold it through a group are not listed.
|
||||
"""
|
||||
|
||||
users: list[int]
|
||||
groups: list[int]
|
||||
|
||||
|
||||
class PermissionGranteesUpdate(TypedDict, total=False):
|
||||
"""
|
||||
The user and group ids to grant one permission action to. A missing key
|
||||
leaves that kind of grant untouched.
|
||||
"""
|
||||
|
||||
users: Iterable[int]
|
||||
groups: Iterable[int]
|
||||
|
||||
|
||||
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGranteesUpdate]
|
||||
|
||||
|
||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||
"""
|
||||
@@ -119,10 +90,101 @@ def has_system_status_permission(user: User | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def get_groups_with_only_permission(obj, codename):
|
||||
ctype = ContentType.objects.get_for_model(obj)
|
||||
permission = Permission.objects.get(content_type=ctype, codename=codename)
|
||||
group_object_perm_group_ids = (
|
||||
GroupObjectPermission.objects.filter(
|
||||
object_pk=obj.pk,
|
||||
content_type=ctype,
|
||||
)
|
||||
.filter(permission=permission)
|
||||
.values_list("group_id")
|
||||
)
|
||||
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||
|
||||
|
||||
def set_permissions_for_object(
|
||||
permissions: dict,
|
||||
object,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set permissions for an object. The permissions are given as a mapping of actions
|
||||
to a dict of user / group id lists, e.g.
|
||||
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
||||
|
||||
If merge is True, the permissions are merged with the existing permissions and
|
||||
no users or groups are removed. If False, the permissions are set to exactly
|
||||
the given list of users and groups.
|
||||
"""
|
||||
|
||||
for action, entry in permissions.items():
|
||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||
if "users" in entry:
|
||||
# users
|
||||
users_to_add = User.objects.filter(id__in=entry["users"])
|
||||
users_to_remove = (
|
||||
get_users_with_perms(
|
||||
object,
|
||||
only_with_perms_in=[permission],
|
||||
with_group_users=False,
|
||||
)
|
||||
if not merge
|
||||
else User.objects.none()
|
||||
)
|
||||
if users_to_add.exists() and users_to_remove.exists():
|
||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||
if users_to_remove.exists():
|
||||
for user in users_to_remove:
|
||||
remove_perm(permission, user, object)
|
||||
if users_to_add.exists():
|
||||
for user in users_to_add:
|
||||
assign_perm(permission, user, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
user,
|
||||
object,
|
||||
)
|
||||
if "groups" in entry:
|
||||
# groups
|
||||
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
||||
groups_to_remove = (
|
||||
get_groups_with_only_permission(
|
||||
object,
|
||||
permission,
|
||||
)
|
||||
if not merge
|
||||
else Group.objects.none()
|
||||
)
|
||||
if groups_to_add.exists() and groups_to_remove.exists():
|
||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||
if groups_to_remove.exists():
|
||||
for group in groups_to_remove:
|
||||
remove_perm(permission, group, object)
|
||||
if groups_to_add.exists():
|
||||
for group in groups_to_add:
|
||||
assign_perm(permission, group, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
group,
|
||||
object,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||
"""
|
||||
Resolve ``codenames`` to Permission rows, raising for any that don't exist
|
||||
so a bad action name fails loudly instead of granting nothing.
|
||||
Resolves `codenames` to Permission rows, raising like the single-object
|
||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
|
||||
action names at the API, but a caller passing one directly would
|
||||
otherwise get a plain `.filter()` that silently builds zero rows and
|
||||
no-ops instead of reporting the bad input.
|
||||
"""
|
||||
permission_objs = list(
|
||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||
@@ -141,13 +203,16 @@ def _apply_bulk_permission_entry(
|
||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||
identity_model: type[User] | type[Group],
|
||||
identity_field: str,
|
||||
ids: Iterable[int],
|
||||
ids: list[int],
|
||||
codename: str,
|
||||
permission_objs: list[Permission],
|
||||
ctype: ContentType,
|
||||
object_pks: list[str],
|
||||
merge: bool,
|
||||
) -> None:
|
||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||
# so avoid fetching full User/Group rows for identities that may not
|
||||
# even end up being granted anything new.
|
||||
add_ids = set(
|
||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||
)
|
||||
@@ -185,26 +250,45 @@ def _apply_bulk_permission_entry(
|
||||
for pk in object_pks
|
||||
for identity_id in add_ids
|
||||
]
|
||||
# ignore_conflicts skips grants that already exist (unique constraint).
|
||||
# ignore_conflicts skips only rows that already exist as an exact
|
||||
# (identity, permission, object) match -- the same de-dup the
|
||||
# underlying (user|group, permission, object_pk) unique constraint
|
||||
# already enforces for the single-object assign_perm() this replaces,
|
||||
# so it doesn't change what counts as "already granted". batch_size
|
||||
# caps how many rows go into a single INSERT statement.
|
||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||
|
||||
|
||||
def set_permissions_for_objects(
|
||||
permissions: PermissionsSpec,
|
||||
permissions: dict,
|
||||
model: type[Model],
|
||||
pks: QuerySet | list,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set the view/change grants for every object in ``pks``. With ``merge``,
|
||||
the given users and groups are added; without it, each given action's
|
||||
direct grants are replaced. Granting change also grants view, and an
|
||||
action entry with neither key is left untouched.
|
||||
Bulk equivalent of set_permissions_for_object: applies the same
|
||||
permission changes to every object identified by `pks` at once.
|
||||
|
||||
guardian's bulk assign_perm is avoided because it skips the direct row
|
||||
for anyone who already has access through a group, so revoking the group
|
||||
later would silently remove access that was granted explicitly.
|
||||
Takes a model + pks (rather than model instances) deliberately -- the
|
||||
permission rows built below only ever need `pk`, `content_type`, and
|
||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||
other field) just to hand them to this function.
|
||||
|
||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||
passing a list as the object routes to bulk_assign_perm, which skips
|
||||
creating a direct permission row for anyone who already has the
|
||||
permission via ANY group membership (it checks
|
||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||
unlike the single-object assign_perm this replaces, which always
|
||||
ensures a direct row via get_or_create regardless of group-derived
|
||||
access. Losing that guarantee would mean a later revocation of the
|
||||
group's grant silently strips access an admin explicitly asked to be
|
||||
direct. Bulk-creating rows straight against the permission models
|
||||
instead (see _apply_bulk_permission_entry) preserves the original
|
||||
always-create-a-direct-row semantics while still batching every object
|
||||
and every identity into one query per action, rather than one query per
|
||||
(object, user) pair.
|
||||
"""
|
||||
object_pks = [str(pk) for pk in pks]
|
||||
if not object_pks: # pragma: no cover
|
||||
@@ -213,9 +297,12 @@ def set_permissions_for_objects(
|
||||
model_name = model.__name__.lower()
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
|
||||
# Resolve every action before writing, so a bad one can't leave the
|
||||
# actions ahead of it applied.
|
||||
permissions_by_action: dict[PermissionAction, list[Permission]] = {}
|
||||
# Every action is resolved up front, before anything is written, so an
|
||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||
# call instead of leaving the actions ahead of it already applied.
|
||||
# SetPermissionsSerializer rejects unknown actions at the API, so this
|
||||
# guards any other caller.
|
||||
permissions_by_action: dict[str, list[Permission]] = {}
|
||||
for action, entry in permissions.items():
|
||||
if "users" not in entry and "groups" not in entry:
|
||||
continue
|
||||
@@ -259,56 +346,6 @@ def set_permissions_for_objects(
|
||||
)
|
||||
|
||||
|
||||
def get_permission_grantees(
|
||||
model: type[Model],
|
||||
pks: Iterable[int],
|
||||
actions: Iterable[PermissionAction] = PERMISSION_ACTIONS,
|
||||
) -> dict[int, dict[PermissionAction, PermissionGrantees]]:
|
||||
"""
|
||||
Return the users and groups holding each of ``actions`` directly on each
|
||||
object of ``model`` identified by ``pks``, in two queries however many
|
||||
objects are asked about.
|
||||
|
||||
Every requested pk and action has an entry, with empty lists where nothing
|
||||
is granted. Ids are sorted. Group grants stay group ids rather than being
|
||||
expanded to their members, so a caller that copies them keeps access tied
|
||||
to current group membership.
|
||||
"""
|
||||
pks = list(pks)
|
||||
actions = list(actions)
|
||||
model_name = model.__name__.lower()
|
||||
action_by_codename: dict[str, PermissionAction] = {
|
||||
f"{action}_{model_name}": action for action in actions
|
||||
}
|
||||
result: dict[int, dict[PermissionAction, PermissionGrantees]] = {
|
||||
pk: {action: {"users": [], "groups": []} for action in actions} for pk in pks
|
||||
}
|
||||
if not pks or not actions:
|
||||
return result
|
||||
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
grant_filter = {
|
||||
"content_type": ctype,
|
||||
"permission__content_type": ctype,
|
||||
"permission__codename__in": list(action_by_codename),
|
||||
"object_pk__in": [str(pk) for pk in pks],
|
||||
}
|
||||
for perm_model, identity_field, key in (
|
||||
(UserObjectPermission, "user_id", "users"),
|
||||
(GroupObjectPermission, "group_id", "groups"),
|
||||
):
|
||||
rows = (
|
||||
perm_model.objects.filter(**grant_filter)
|
||||
.values_list("object_pk", "permission__codename", identity_field)
|
||||
.order_by(identity_field)
|
||||
)
|
||||
for object_pk, codename, identity_id in rows:
|
||||
result[int(object_pk)][action_by_codename[codename]][key].append(
|
||||
identity_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def permitted_object_ids(
|
||||
user: User | None,
|
||||
model: type[Model],
|
||||
@@ -474,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,
|
||||
*,
|
||||
|
||||
@@ -48,6 +48,8 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from documents.models import Document
|
||||
@@ -526,8 +528,8 @@ class TantivyBackend:
|
||||
Annotate the queryset with ``annotate_effective_content`` when indexing
|
||||
more than a couple of documents, to resolve that without a query each.
|
||||
"""
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_permission_grantees
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
|
||||
# Every searchable string is normalized on the way in, and every
|
||||
# query string on the way out (_parse_query), so the two agree on
|
||||
@@ -672,18 +674,28 @@ class TantivyBackend:
|
||||
doc.add_unsigned("owner_id", document.owner_id)
|
||||
|
||||
# Viewers with permission
|
||||
if viewer_ids is None or viewer_group_ids is None:
|
||||
view = get_permission_grantees(
|
||||
Document,
|
||||
[document.pk],
|
||||
actions=("view",),
|
||||
)[document.pk]["view"]
|
||||
if viewer_ids is None:
|
||||
viewer_ids = view["users"]
|
||||
if viewer_group_ids is None:
|
||||
viewer_group_ids = view["groups"]
|
||||
if viewer_ids is None:
|
||||
users_with_perms = get_users_with_perms(
|
||||
document,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
)
|
||||
viewer_ids = list(
|
||||
cast("QuerySet[User]", users_with_perms).values_list("id", flat=True),
|
||||
)
|
||||
for viewer_id in viewer_ids:
|
||||
doc.add_unsigned("viewer_id", viewer_id)
|
||||
if viewer_group_ids is None:
|
||||
groups_with_perms = get_groups_with_perms(
|
||||
document,
|
||||
only_with_perms_in=["view_document"],
|
||||
)
|
||||
viewer_group_ids = list(
|
||||
cast("QuerySet[Group]", groups_with_perms).values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
),
|
||||
)
|
||||
for viewer_group_id in viewer_group_ids:
|
||||
doc.add_unsigned("viewer_group_id", viewer_group_id)
|
||||
|
||||
@@ -1188,6 +1200,12 @@ def chunked(iterable, size):
|
||||
yield chunk
|
||||
|
||||
|
||||
_EMPTY_VIEWER_GRANT: Final[ViewerGrant] = ViewerGrant(
|
||||
viewer_ids=[],
|
||||
viewer_group_ids=[],
|
||||
)
|
||||
|
||||
|
||||
class _DocumentViewerStream(QuerySetStream["Document"]):
|
||||
"""Yield document permission data while batch-loading grants.
|
||||
|
||||
@@ -1213,7 +1231,7 @@ class _DocumentViewerStream(QuerySetStream["Document"]):
|
||||
for chunk in chunked(documents, self._chunk_size):
|
||||
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
|
||||
for doc in chunk:
|
||||
yield doc, grants_by_pk[doc.pk]
|
||||
yield doc, grants_by_pk.get(doc.pk, _EMPTY_VIEWER_GRANT)
|
||||
|
||||
|
||||
def _bulk_get_viewer_permissions(
|
||||
@@ -1225,19 +1243,48 @@ def _bulk_get_viewer_permissions(
|
||||
requesting user's current memberships. Expanding groups to user IDs here
|
||||
would leave stale access behind after a user is removed from a group.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_permission_grantees
|
||||
|
||||
# get_for_model is cached by Django, so this costs at most one query total.
|
||||
ct = ContentType.objects.get_for_model(Document)
|
||||
str_pks = [str(pk) for pk in doc_pks]
|
||||
|
||||
viewer_map: dict[int, set[int]] = defaultdict(set)
|
||||
viewer_group_map: dict[int, set[int]] = defaultdict(set)
|
||||
|
||||
# Fold the permission lookup into the query via a join on codename instead
|
||||
# of a separate Permission.objects.get(), which would otherwise run once per
|
||||
# chunk during a full reindex.
|
||||
user_qs = UserObjectPermission.objects.filter(
|
||||
content_type=ct,
|
||||
permission__content_type=ct,
|
||||
permission__codename="view_document",
|
||||
object_pk__in=str_pks,
|
||||
).values_list("object_pk", "user_id")
|
||||
for object_pk, user_id in user_qs:
|
||||
viewer_map[int(object_pk)].add(user_id)
|
||||
|
||||
group_qs = GroupObjectPermission.objects.filter(
|
||||
content_type=ct,
|
||||
permission__content_type=ct,
|
||||
permission__codename="view_document",
|
||||
object_pk__in=str_pks,
|
||||
).values_list("object_pk", "group_id")
|
||||
for object_pk, group_id in group_qs:
|
||||
viewer_group_map[int(object_pk)].add(group_id)
|
||||
|
||||
return {
|
||||
pk: ViewerGrant(
|
||||
viewer_ids=grantees["view"]["users"],
|
||||
viewer_group_ids=grantees["view"]["groups"],
|
||||
object_pk: ViewerGrant(
|
||||
viewer_ids=list(viewer_map.get(object_pk, ())),
|
||||
viewer_group_ids=list(viewer_group_map.get(object_pk, ())),
|
||||
)
|
||||
for pk, grantees in get_permission_grantees(
|
||||
Document,
|
||||
doc_pks,
|
||||
actions=("view",),
|
||||
).items()
|
||||
for object_pk in viewer_map.keys() | viewer_group_map.keys()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from typing import TypedDict
|
||||
|
||||
import magic
|
||||
@@ -38,6 +39,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
from rest_framework import fields
|
||||
@@ -76,17 +78,12 @@ from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import PERMISSION_ACTIONS
|
||||
from documents.permissions import PermissionAction
|
||||
from documents.permissions import PermissionGrantees
|
||||
from documents.permissions import PermissionGranteesUpdate
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_permission_grantees
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
@@ -214,6 +211,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
return match
|
||||
|
||||
|
||||
PERMISSION_ACTIONS = ("view", "change")
|
||||
|
||||
|
||||
class SetPermissionsMixin:
|
||||
def _validate_user_ids(self, user_ids):
|
||||
users = User.objects.none()
|
||||
@@ -235,10 +235,8 @@ class SetPermissionsMixin:
|
||||
)
|
||||
return groups
|
||||
|
||||
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec:
|
||||
permissions_dict: dict[PermissionAction, PermissionGranteesUpdate] = {
|
||||
action: {} for action in PERMISSION_ACTIONS
|
||||
}
|
||||
def validate_set_permissions(self, set_permissions=None):
|
||||
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
||||
if set_permissions is not None:
|
||||
for action in PERMISSION_ACTIONS:
|
||||
if action in set_permissions:
|
||||
@@ -246,18 +244,18 @@ class SetPermissionsMixin:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||
users,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
if "groups" in set_permissions[action]:
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
else:
|
||||
del permissions_dict[action]
|
||||
return permissions_dict
|
||||
|
||||
def _set_permissions(self, permissions: PermissionsSpec, object) -> None:
|
||||
set_permissions_for_objects(permissions, type(object), [object.pk])
|
||||
def _set_permissions(self, permissions, object) -> None:
|
||||
set_permissions_for_object(permissions, object)
|
||||
|
||||
|
||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||
@@ -313,6 +311,35 @@ class OwnedObjectSerializer(
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
|
||||
"""
|
||||
Get the given permissions from context or from django-guardian.
|
||||
|
||||
:param codename: The permission codename, e.g. 'view' or 'change'
|
||||
:param target: 'users' or 'groups'
|
||||
"""
|
||||
key = f"{target}_{codename}_perms"
|
||||
cached = self.context.get(key, {}).get(obj.pk)
|
||||
if cached is not None:
|
||||
return list(cached)
|
||||
|
||||
# Permission not found in the context, get it from guardian
|
||||
if target == "users":
|
||||
return list(
|
||||
get_users_with_perms(
|
||||
obj,
|
||||
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
else: # groups
|
||||
return list(
|
||||
get_groups_with_only_permission(
|
||||
obj,
|
||||
codename=f"{codename}_{obj.__class__.__name__.lower()}",
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
field={
|
||||
"type": "object",
|
||||
@@ -346,12 +373,17 @@ class OwnedObjectSerializer(
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_permissions(self, obj) -> dict[PermissionAction, PermissionGrantees]:
|
||||
# Prefer the page-level batch computed by BulkPermissionMixin.
|
||||
grantees = self.context.get("permission_grantees", {}).get(obj.pk)
|
||||
if grantees is not None:
|
||||
return grantees
|
||||
return get_permission_grantees(type(obj), [obj.pk])[obj.pk]
|
||||
def get_permissions(self, obj) -> dict:
|
||||
return {
|
||||
"view": {
|
||||
"users": self._get_perms(obj, "view", "users"),
|
||||
"groups": self._get_perms(obj, "view", "groups"),
|
||||
},
|
||||
"change": {
|
||||
"users": self._get_perms(obj, "change", "users"),
|
||||
"groups": self._get_perms(obj, "change", "groups"),
|
||||
},
|
||||
}
|
||||
|
||||
def get_user_can_change(self, obj) -> bool:
|
||||
if obj.owner is None or obj.owner == self.user:
|
||||
@@ -367,18 +399,18 @@ class OwnedObjectSerializer(
|
||||
# Prefer the page-level batch computed by BulkPermissionMixin
|
||||
# (get_serializer_context) over a fresh per-object guardian check,
|
||||
# which would otherwise query the permission tables once per row.
|
||||
grantees_by_pk = self.context.get("permission_grantees")
|
||||
if grantees_by_pk is not None:
|
||||
change = grantees_by_pk.get(obj.pk, {}).get("change")
|
||||
if change is None:
|
||||
return False
|
||||
if self.user.pk in change["users"]:
|
||||
users_change_perms = self.context.get("users_change_perms")
|
||||
groups_change_perms = self.context.get("groups_change_perms")
|
||||
if users_change_perms is not None and groups_change_perms is not None:
|
||||
if self.user.pk in users_change_perms.get(obj.pk, []):
|
||||
return True
|
||||
user_group_ids = getattr(self, "_user_group_ids", None)
|
||||
if user_group_ids is None:
|
||||
user_group_ids = set(self.user.groups.values_list("id", flat=True))
|
||||
self._user_group_ids = user_group_ids
|
||||
return bool(user_group_ids.intersection(change["groups"]))
|
||||
return bool(
|
||||
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
|
||||
)
|
||||
|
||||
checker = ObjectPermissionChecker(self.user)
|
||||
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
||||
@@ -3054,7 +3086,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
)
|
||||
return objects
|
||||
|
||||
def _validate_permissions(self, permissions) -> PermissionsSpec:
|
||||
def _validate_permissions(self, permissions) -> dict:
|
||||
return self.validate_set_permissions(
|
||||
permissions,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -23,7 +23,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
@@ -446,7 +445,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -489,7 +488,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
grant_object(self.user1, self.doc1, "view_document")
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -540,7 +539,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
batched call rather than one call per (document, identity)
|
||||
pair
|
||||
"""
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -647,7 +646,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
"""
|
||||
with self.assertRaises(Permission.DoesNotExist):
|
||||
set_permissions_for_objects(
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}}, # type: ignore[bad-assignment]
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
)
|
||||
@@ -675,7 +674,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
set_permissions_for_objects(
|
||||
{
|
||||
"view": {"users": [self.user1.id], "groups": []},
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []}, # type: ignore[bad-assignment]
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||
},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -15,11 +15,9 @@ from documents.matching import match_document_types
|
||||
from documents.matching import match_storage_paths
|
||||
from documents.matching import match_tags
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import get_permission_grantees
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
@@ -1374,101 +1372,3 @@ class TestRestrictQuerysetToVisible:
|
||||
|
||||
assert own.pk in visible_ids
|
||||
assert hidden.pk not in visible_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGetPermissionGrantees:
|
||||
def test_lists_direct_grants_per_object_and_action(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents, the first with view and change granted to a user
|
||||
and a group, the second with only view granted to another user
|
||||
- A user who is a member of the granted group
|
||||
WHEN:
|
||||
- get_permission_grantees() is called for both documents
|
||||
THEN:
|
||||
- Each document and action lists exactly its direct grants
|
||||
- The group member is not listed as a user
|
||||
"""
|
||||
direct = UserFactory()
|
||||
other = UserFactory()
|
||||
member = UserFactory()
|
||||
group = Group.objects.create(name="grantees")
|
||||
member.groups.add(group)
|
||||
first = DocumentFactory()
|
||||
second = DocumentFactory()
|
||||
grant_object(direct, first, "view_document", "change_document")
|
||||
grant_object(group, first, "view_document", "change_document")
|
||||
grant_object(other, second, "view_document")
|
||||
|
||||
grantees = get_permission_grantees(Document, [first.pk, second.pk])
|
||||
|
||||
assert grantees == {
|
||||
first.pk: {
|
||||
"view": {"users": [direct.pk], "groups": [group.pk]},
|
||||
"change": {"users": [direct.pk], "groups": [group.pk]},
|
||||
},
|
||||
second.pk: {
|
||||
"view": {"users": [other.pk], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
},
|
||||
}
|
||||
|
||||
def test_ids_are_sorted(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag with view granted to three users in reverse creation order
|
||||
WHEN:
|
||||
- get_permission_grantees() is called for the tag
|
||||
THEN:
|
||||
- The user ids come back in ascending order
|
||||
"""
|
||||
users = [UserFactory() for _ in range(3)]
|
||||
tag = TagFactory()
|
||||
for user in reversed(users):
|
||||
grant_object(user, tag, "view_tag")
|
||||
|
||||
grantees = get_permission_grantees(Tag, [tag.pk], actions=("view",))
|
||||
|
||||
assert grantees[tag.pk]["view"]["users"] == sorted(user.pk for user in users)
|
||||
|
||||
def test_only_requested_actions_are_returned(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag with view and change granted to a user
|
||||
WHEN:
|
||||
- get_permission_grantees() is called for the view action only
|
||||
THEN:
|
||||
- Only the view action is returned
|
||||
"""
|
||||
user = UserFactory()
|
||||
tag = TagFactory()
|
||||
grant_object(user, tag, "view_tag", "change_tag")
|
||||
|
||||
grantees = get_permission_grantees(Tag, [tag.pk], actions=("view",))
|
||||
|
||||
assert grantees == {tag.pk: {"view": {"users": [user.pk], "groups": []}}}
|
||||
|
||||
def test_grants_on_another_model_with_the_same_pk_are_ignored(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag and a correspondent sharing a primary key, with view
|
||||
granted to a user on the tag only
|
||||
WHEN:
|
||||
- get_permission_grantees() is called for the correspondent
|
||||
THEN:
|
||||
- No grants are returned
|
||||
"""
|
||||
user = UserFactory()
|
||||
tag = TagFactory()
|
||||
correspondent = CorrespondentFactory(pk=tag.pk)
|
||||
grant_object(user, tag, "view_tag")
|
||||
|
||||
grantees = get_permission_grantees(Correspondent, [correspondent.pk])
|
||||
|
||||
assert grantees == {
|
||||
correspondent.pk: {
|
||||
"view": {"users": [], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
},
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -292,33 +292,6 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
else:
|
||||
assert False, f"Unexpected tag found: {tag['name']}"
|
||||
|
||||
def test_retrieve_with_full_permissions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag with view granted to two groups and change granted to only
|
||||
one of them
|
||||
WHEN:
|
||||
- The single tag is requested with full permissions
|
||||
THEN:
|
||||
- Each action lists only the groups granted that action
|
||||
"""
|
||||
view_only = Group.objects.create(name="view_only")
|
||||
view_and_change = Group.objects.create(name="view_and_change")
|
||||
tag = Tag.objects.create(name="invoice")
|
||||
grant_object(view_only, tag, "view_tag")
|
||||
grant_object(view_and_change, tag, "view_tag", "change_tag")
|
||||
grant_global(self.user, "view_tag")
|
||||
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(f"/api/tags/{tag.pk}/?full_perms=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
permissions = response.json()["permissions"]
|
||||
assert sorted(permissions["view"]["groups"]) == sorted(
|
||||
[view_only.pk, view_and_change.pk],
|
||||
)
|
||||
assert permissions["change"]["groups"] == [view_and_change.pk]
|
||||
|
||||
def test_list_no_n_plus_1_queries(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:
|
||||
|
||||
+80
-10
@@ -5,6 +5,7 @@ import platform
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
@@ -16,6 +17,7 @@ from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
from typing import Literal
|
||||
from typing import NamedTuple
|
||||
from unicodedata import normalize
|
||||
from urllib.parse import quote
|
||||
@@ -27,6 +29,7 @@ import pathvalidate
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db import connections
|
||||
from django.db.migrations.loader import MigrationLoader
|
||||
@@ -74,6 +77,8 @@ from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from drf_spectacular.utils import inline_serializer
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
from langdetect import detect
|
||||
from packaging import version as packaging_version
|
||||
from redis import Redis
|
||||
@@ -171,7 +176,6 @@ 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 get_permission_grantees
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
@@ -451,6 +455,42 @@ class BulkPermissionMixin:
|
||||
Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.
|
||||
"""
|
||||
|
||||
def _get_object_perms(
|
||||
self,
|
||||
objects: list,
|
||||
perm_codenames: list[str],
|
||||
actor: Literal["users", "groups"],
|
||||
) -> dict[int, dict[str, list[int]]]:
|
||||
"""
|
||||
Collect object-level permissions for either users or groups.
|
||||
"""
|
||||
model = self.queryset.model
|
||||
obj_perm_model = (
|
||||
get_user_obj_perms_model(model)
|
||||
if actor == "users"
|
||||
else get_group_obj_perms_model(model)
|
||||
)
|
||||
id_field = "user_id" if actor == "users" else "group_id"
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
object_pks = [obj.pk for obj in objects]
|
||||
|
||||
perms_qs = obj_perm_model.objects.filter(
|
||||
content_type=ctype,
|
||||
object_pk__in=object_pks,
|
||||
permission__codename__in=perm_codenames,
|
||||
).values_list("object_pk", id_field, "permission__codename")
|
||||
|
||||
perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list))
|
||||
for object_pk, actor_id, codename in perms_qs:
|
||||
perms[int(object_pk)][codename].append(actor_id)
|
||||
|
||||
# Ensure that all objects have all codenames, even if empty
|
||||
for pk in object_pks:
|
||||
for codename in perm_codenames:
|
||||
perms[pk][codename]
|
||||
|
||||
return perms
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Get all permissions of the current list of objects at once and pass them to the serializer.
|
||||
@@ -461,7 +501,7 @@ class BulkPermissionMixin:
|
||||
if getattr(self, "action", None) != "list":
|
||||
# Batching only pays off across a page of objects; for single-object
|
||||
# actions (retrieve, update, ...) the per-object fallback in
|
||||
# get_user_can_change()/get_permissions() is cheap and avoids scanning
|
||||
# get_user_can_change()/_get_perms() is cheap and avoids scanning
|
||||
# the whole queryset here.
|
||||
return context
|
||||
|
||||
@@ -474,10 +514,33 @@ class BulkPermissionMixin:
|
||||
else:
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
context["permission_grantees"] = get_permission_grantees(
|
||||
self.queryset.model,
|
||||
[obj.pk for obj in queryset],
|
||||
model_name = self.queryset.model.__name__.lower()
|
||||
permission_name_view = f"view_{model_name}"
|
||||
permission_name_change = f"change_{model_name}"
|
||||
|
||||
user_perms = self._get_object_perms(
|
||||
objects=queryset,
|
||||
perm_codenames=[permission_name_view, permission_name_change],
|
||||
actor="users",
|
||||
)
|
||||
group_perms = self._get_object_perms(
|
||||
objects=queryset,
|
||||
perm_codenames=[permission_name_view, permission_name_change],
|
||||
actor="groups",
|
||||
)
|
||||
|
||||
context["users_view_perms"] = {
|
||||
pk: user_perms[pk][permission_name_view] for pk in user_perms
|
||||
}
|
||||
context["users_change_perms"] = {
|
||||
pk: user_perms[pk][permission_name_change] for pk in user_perms
|
||||
}
|
||||
context["groups_view_perms"] = {
|
||||
pk: group_perms[pk][permission_name_view] for pk in group_perms
|
||||
}
|
||||
context["groups_change_perms"] = {
|
||||
pk: group_perms[pk][permission_name_change] for pk in group_perms
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
@@ -3869,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,
|
||||
@@ -4848,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,
|
||||
@@ -4857,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,
|
||||
|
||||
@@ -7,8 +7,7 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import WorkflowAction
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
logger = logging.getLogger("paperless.workflows.mutations")
|
||||
@@ -74,7 +73,7 @@ def apply_assignment_to_document(
|
||||
action.has_assign_change_groups,
|
||||
],
|
||||
):
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": action.assign_view_users.values_list("id", flat=True),
|
||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||
@@ -84,10 +83,9 @@ def apply_assignment_to_document(
|
||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||
},
|
||||
}
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=True,
|
||||
)
|
||||
|
||||
@@ -245,14 +243,13 @@ def apply_removal_to_document(
|
||||
document.owner = None
|
||||
|
||||
if action.remove_all_permissions:
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {"users": [], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
}
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=False,
|
||||
)
|
||||
|
||||
|
||||
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 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user