mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-29 02:55:43 +00:00
Compare commits
22
Commits
v2.20.12
...
e5561ba06f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5561ba06f | ||
|
|
1692c916f8 | ||
|
|
ffd886eae0 | ||
|
|
12c0dc635e | ||
|
|
1e01ce42c0 | ||
|
|
e46f4a5aaa | ||
|
|
1ba6c31385 | ||
|
|
f3ee820fa4 | ||
|
|
2f5bcdf66e | ||
|
|
501cdd92d2 | ||
|
|
66c5c46913 | ||
|
|
782634d912 | ||
|
|
3cfe9fa2a8 | ||
|
|
784fed447f | ||
|
|
3292a0e7cc | ||
|
|
9646b8c67d | ||
|
|
e590d7df69 | ||
|
|
cc71aad058 | ||
|
|
3cbdf5d0b7 | ||
|
|
f84e0097e5 | ||
|
|
7dbf8bdd4a | ||
|
|
d2a752a196 |
No files matched your search
@@ -761,7 +761,7 @@ MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
|||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks. Barcodes are only supported for PDF documents or TIFF, [if enabled](configuration.md#PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT).
|
||||||
|
|
||||||
At this time, the library utilized for detection of barcodes supports the following types:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,67 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.14
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
|
||||||
|
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
|
||||||
|
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
|
||||||
|
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
|
||||||
|
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
|
||||||
|
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
|
||||||
|
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>7 changes</summary>
|
||||||
|
|
||||||
|
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
|
||||||
|
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
|
||||||
|
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
|
||||||
|
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
|
||||||
|
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
|
||||||
|
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
|
||||||
|
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.13
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: suggest corrections only if visible results
|
||||||
|
- Fix: require view permission for more-like search
|
||||||
|
- Fix: validate document link targets
|
||||||
|
- Fix: enforce permissions when attaching accounts to mail rules
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.12
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
|
||||||
|
- Fix: don't try to usermod/groupmod when non-root + update docs (#<!---->12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
|
||||||
|
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
|
||||||
|
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
|
||||||
|
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
|
||||||
|
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>5 changes</summary>
|
||||||
|
|
||||||
|
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
|
||||||
|
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
|
||||||
|
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
|
||||||
|
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
|
||||||
|
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 2.20.11
|
## paperless-ngx 2.20.11
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+3
-1
@@ -381,7 +381,7 @@ still have "object-level" permissions.
|
|||||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||||
| Tag | Add, edit, delete or view Tags. |
|
| Tag | Add, edit, delete or view Tags. |
|
||||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||||
| User | Add, edit, delete or view Users. |
|
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||||
|
|
||||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
@@ -392,6 +392,8 @@ still have "object-level" permissions.
|
|||||||
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
||||||
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
||||||
|
|
||||||
|
For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail.
|
||||||
|
|
||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.12"
|
version = "2.20.14"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.20.12",
|
"version": "2.20.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -87,6 +88,7 @@ describe('EditDialogComponent', () => {
|
|||||||
let component: TestComponent
|
let component: TestComponent
|
||||||
let fixture: ComponentFixture<TestComponent>
|
let fixture: ComponentFixture<TestComponent>
|
||||||
let tagService: TagService
|
let tagService: TagService
|
||||||
|
let permissionsService: PermissionsService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
let activeModal: NgbActiveModal
|
let activeModal: NgbActiveModal
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
@@ -118,8 +120,10 @@ describe('EditDialogComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
tagService = TestBed.inject(TagService)
|
tagService = TestBed.inject(TagService)
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = currentUser
|
settingsService.currentUser = currentUser
|
||||||
|
permissionsService.initialize([], currentUser as any)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
|
||||||
@@ -226,6 +230,25 @@ describe('EditDialogComponent', () => {
|
|||||||
expect(updateSpy).toHaveBeenCalled()
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not submit owner or permissions for non-owner edits', () => {
|
||||||
|
component.object = tag
|
||||||
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
component.objectForm.get('name').setValue('Updated tag')
|
||||||
|
component.save()
|
||||||
|
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}tags/${tag.id}/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('PUT')
|
||||||
|
expect(req.request.body.name).toEqual('Updated tag')
|
||||||
|
expect(req.request.body.owner).toEqual(tag.owner)
|
||||||
|
expect(req.request.body.set_permissions).toBeUndefined()
|
||||||
|
|
||||||
|
req.flush({})
|
||||||
|
})
|
||||||
|
|
||||||
it('should create an object on save in edit mode', () => {
|
it('should create an object on save in edit mode', () => {
|
||||||
const createSpy = jest.spyOn(tagService, 'create')
|
const createSpy = jest.spyOn(tagService, 'create')
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
|
|||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
|
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -42,6 +43,7 @@ export abstract class EditDialogComponent<
|
|||||||
protected activeModal = inject(NgbActiveModal)
|
protected activeModal = inject(NgbActiveModal)
|
||||||
protected userService = inject(UserService)
|
protected userService = inject(UserService)
|
||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
users: User[]
|
users: User[]
|
||||||
|
|
||||||
@@ -69,10 +71,6 @@ export abstract class EditDialogComponent<
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
||||||
if ((this.object as ObjectWithPermissions).permissions) {
|
|
||||||
this.object['set_permissions'] = this.object['permissions']
|
|
||||||
}
|
|
||||||
|
|
||||||
this.object['permissions_form'] = {
|
this.object['permissions_form'] = {
|
||||||
owner: (this.object as ObjectWithPermissions).owner,
|
owner: (this.object as ObjectWithPermissions).owner,
|
||||||
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
||||||
@@ -151,18 +149,28 @@ export abstract class EditDialogComponent<
|
|||||||
return Object.assign({}, this.objectForm.value)
|
return Object.assign({}, this.objectForm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected shouldSubmitPermissions(): boolean {
|
||||||
|
return (
|
||||||
|
this.dialogMode === EditDialogMode.CREATE ||
|
||||||
|
this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.error = null
|
this.error = null
|
||||||
const formValues = this.getFormValues()
|
const formValues = this.getFormValues()
|
||||||
const permissionsObject: PermissionsFormObject =
|
const permissionsObject: PermissionsFormObject =
|
||||||
this.objectForm.get('permissions_form')?.value
|
this.objectForm.get('permissions_form')?.value
|
||||||
if (permissionsObject) {
|
if (permissionsObject && this.shouldSubmitPermissions()) {
|
||||||
formValues.owner = permissionsObject.owner
|
formValues.owner = permissionsObject.owner
|
||||||
formValues.set_permissions = permissionsObject.set_permissions
|
formValues.set_permissions = permissionsObject.set_permissions
|
||||||
delete formValues.permissions_form
|
|
||||||
}
|
}
|
||||||
|
delete formValues.permissions_form
|
||||||
|
|
||||||
var newObject = Object.assign(Object.assign({}, this.object), formValues)
|
var newObject = Object.assign(Object.assign({}, this.object), formValues)
|
||||||
|
if (!this.shouldSubmitPermissions()) {
|
||||||
|
delete newObject['set_permissions']
|
||||||
|
}
|
||||||
var serverResponse: Observable<T>
|
var serverResponse: Observable<T>
|
||||||
switch (this.dialogMode) {
|
switch (this.dialogMode) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
|
|||||||
-2
@@ -9,7 +9,6 @@ import { first } from 'rxjs'
|
|||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -39,7 +38,6 @@ export class UserEditDialogComponent
|
|||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private permissionsService = inject(PermissionsService)
|
|
||||||
private groupsService: GroupService
|
private groupsService: GroupService
|
||||||
|
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
|
|||||||
@@ -205,6 +205,20 @@ describe('TagsComponent', () => {
|
|||||||
expect(component.value).toEqual([2, 1])
|
expect(component.value).toEqual([2, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not duplicate parents when adding sibling nested tags', () => {
|
||||||
|
const root: Tag = { id: 1, name: 'root' }
|
||||||
|
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
||||||
|
const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 }
|
||||||
|
const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 }
|
||||||
|
component.tags = [root, parent, leafA, leafB]
|
||||||
|
|
||||||
|
component.value = []
|
||||||
|
component.addTag(3)
|
||||||
|
component.addTag(4)
|
||||||
|
|
||||||
|
expect(component.value).toEqual([3, 2, 1, 4])
|
||||||
|
})
|
||||||
|
|
||||||
it('should return ancestors from root to parent using getParentChain', () => {
|
it('should return ancestors from root to parent using getParentChain', () => {
|
||||||
const root: Tag = { id: 1, name: 'root' }
|
const root: Tag = { id: 1, name: 'root' }
|
||||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAdd(tag: Tag) {
|
public onAdd(tag: Tag) {
|
||||||
if (tag.parent) {
|
if (tag?.parent) {
|
||||||
// add all parents recursively
|
// add all parents recursively
|
||||||
const parent = this.getTag(tag.parent)
|
const parent = this.getTag(tag.parent)
|
||||||
this.value = [...this.value, parent.id]
|
if (parent && !this.value.includes(parent.id)) {
|
||||||
this.onAdd(parent)
|
this.value = [...this.value, parent.id]
|
||||||
|
this.onAdd(parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const environment = {
|
|||||||
apiVersion: '9', // match src/paperless/settings.py
|
apiVersion: '9', // match src/paperless/settings.py
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '2.20.12',
|
version: '2.20.14',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -470,7 +470,14 @@ class DelayedFullTextQuery(DelayedQuery):
|
|||||||
try:
|
try:
|
||||||
corrected = self.searcher.correct_query(q, q_str)
|
corrected = self.searcher.correct_query(q, q_str)
|
||||||
if corrected.string != q_str:
|
if corrected.string != q_str:
|
||||||
suggested_correction = corrected.string
|
corrected_results = self.searcher.search(
|
||||||
|
corrected.query,
|
||||||
|
limit=1,
|
||||||
|
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
||||||
|
scored=False,
|
||||||
|
)
|
||||||
|
if len(corrected_results) > 0:
|
||||||
|
suggested_correction = corrected.string
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Error while correcting query %s: %s",
|
"Error while correcting query %s: %s",
|
||||||
|
|||||||
@@ -853,6 +853,25 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
|||||||
return {self.field_name: data}
|
return {self.field_name: data}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_documentlink_targets(user, doc_ids):
|
||||||
|
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Some documents in value don't exist or were specified twice.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||||
|
if not all(
|
||||||
|
has_perms_owner_aware(user, "change_document", document)
|
||||||
|
for document in target_documents
|
||||||
|
):
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Insufficient permissions."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||||
@@ -943,12 +962,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
"Value must be a list",
|
"Value must be a list",
|
||||||
)
|
)
|
||||||
doc_ids = data["value"]
|
doc_ids = data["value"]
|
||||||
if Document.objects.filter(id__in=doc_ids).count() != len(
|
request = self.context.get("request")
|
||||||
data["value"],
|
validate_documentlink_targets(
|
||||||
):
|
getattr(request, "user", None) if request is not None else None,
|
||||||
raise serializers.ValidationError(
|
doc_ids,
|
||||||
"Some documents in value don't exist or were specified twice.",
|
)
|
||||||
)
|
elif field.data_type == CustomField.FieldDataType.DATE:
|
||||||
|
data["value"] = serializers.DateField().to_internal_value(data["value"])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -1498,6 +1518,19 @@ class BulkEditSerializer(
|
|||||||
f"Some custom fields in {name} don't exist or were specified twice.",
|
f"Some custom fields in {name} don't exist or were specified twice.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if isinstance(custom_fields, dict):
|
||||||
|
custom_field_map = CustomField.objects.in_bulk(ids)
|
||||||
|
for raw_field_id, value in custom_fields.items():
|
||||||
|
field = custom_field_map.get(int(raw_field_id))
|
||||||
|
if (
|
||||||
|
field is not None
|
||||||
|
and field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
|
and value is not None
|
||||||
|
):
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise serializers.ValidationError("Value must be a list")
|
||||||
|
validate_documentlink_targets(self.user, value)
|
||||||
|
|
||||||
def validate_method(self, method):
|
def validate_method(self, method):
|
||||||
if method == "set_correspondent":
|
if method == "set_correspondent":
|
||||||
return bulk_edit.set_correspondent
|
return bulk_edit.set_correspondent
|
||||||
|
|||||||
@@ -818,7 +818,6 @@ def run_workflows(
|
|||||||
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
||||||
# Otherwise, this instance might be behind and overwrite the work another process did
|
# Otherwise, this instance might be behind and overwrite the work another process did
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
|
||||||
|
|
||||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
if matching.document_matches_workflow(document, workflow, trigger_type):
|
||||||
action: WorkflowAction
|
action: WorkflowAction
|
||||||
@@ -836,14 +835,13 @@ def run_workflows(
|
|||||||
apply_assignment_to_document(
|
apply_assignment_to_document(
|
||||||
action,
|
action,
|
||||||
document,
|
document,
|
||||||
doc_tag_ids,
|
|
||||||
logging_group,
|
logging_group,
|
||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||||
if use_overrides and overrides:
|
if use_overrides and overrides:
|
||||||
apply_removal_to_overrides(action, overrides)
|
apply_removal_to_overrides(action, overrides)
|
||||||
else:
|
else:
|
||||||
apply_removal_to_document(action, document, doc_tag_ids)
|
apply_removal_to_document(action, document)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
||||||
context = build_workflow_action_context(document, overrides)
|
context = build_workflow_action_context(document, overrides)
|
||||||
execute_email_action(
|
execute_email_action(
|
||||||
@@ -886,7 +884,6 @@ def run_workflows(
|
|||||||
"modified",
|
"modified",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
document.tags.set(doc_tag_ids)
|
|
||||||
|
|
||||||
WorkflowRun.objects.create(
|
WorkflowRun.objects.create(
|
||||||
workflow=workflow,
|
workflow=workflow,
|
||||||
|
|||||||
@@ -262,6 +262,50 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
||||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
|
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
):
|
||||||
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
user = User.objects.create_user(username="doc-owner")
|
||||||
|
user.user_permissions.add(Permission.objects.get(codename="change_document"))
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
checksum="source",
|
||||||
|
title="Source",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
checksum="target",
|
||||||
|
title="Target",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
doclink_field = CustomField.objects.create(
|
||||||
|
name="doclink",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [source_doc.id],
|
||||||
|
"method": "modify_custom_fields",
|
||||||
|
"parameters": {
|
||||||
|
"add_custom_fields": {doclink_field.id: [target_doc.id]},
|
||||||
|
"remove_custom_fields": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_with_values(self, m):
|
def test_api_modify_custom_fields_with_values(self, m):
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import ANY
|
|||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -1247,6 +1248,100 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(doc5.custom_fields.first().value, [1])
|
self.assertEqual(doc5.custom_fields.first().value, [1])
|
||||||
|
|
||||||
|
def test_documentlink_patch_requires_change_permission_on_target_documents(self):
|
||||||
|
source_owner = User.objects.create_user(username="source-owner")
|
||||||
|
source_owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
title="Source",
|
||||||
|
checksum="source",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=source_owner,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
title="Target",
|
||||||
|
checksum="target",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
custom_field_doclink = CustomField.objects.create(
|
||||||
|
name="Test Custom Field Doc Link",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=source_owner)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{source_doc.id}/",
|
||||||
|
data={
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": custom_field_doclink.id,
|
||||||
|
"value": [target_doc.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(
|
||||||
|
CustomFieldInstance.objects.filter(field=custom_field_doclink).count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
source_owner = User.objects.create_user(username="source-owner")
|
||||||
|
source_owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="other-user")
|
||||||
|
|
||||||
|
source_doc = Document.objects.create(
|
||||||
|
title="Source",
|
||||||
|
checksum="source",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=source_owner,
|
||||||
|
)
|
||||||
|
target_doc = Document.objects.create(
|
||||||
|
title="Target",
|
||||||
|
checksum="target",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
custom_field_doclink = CustomField.objects.create(
|
||||||
|
name="Test Custom Field Doc Link",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_perm("change_document", source_owner, target_doc)
|
||||||
|
self.client.force_authenticate(user=source_owner)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{source_doc.id}/",
|
||||||
|
data={
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": custom_field_doclink.id,
|
||||||
|
"value": [target_doc.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
target_doc.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
target_doc.custom_fields.get(field=custom_field_doclink).value,
|
||||||
|
[source_doc.id],
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_field_filters(self):
|
def test_custom_field_filters(self):
|
||||||
custom_field_string = CustomField.objects.create(
|
custom_field_string = CustomField.objects.create(
|
||||||
name="Test Custom Field String",
|
name="Test Custom Field String",
|
||||||
@@ -1330,3 +1425,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(results[0]["document_count"], 0)
|
self.assertEqual(results[0]["document_count"], 0)
|
||||||
|
|
||||||
|
def test_patch_document_invalid_date_custom_field_returns_validation_error(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A date custom field
|
||||||
|
- A document
|
||||||
|
WHEN:
|
||||||
|
- Patching the document with a date string in the wrong format
|
||||||
|
THEN:
|
||||||
|
- HTTP 400 is returned instead of an internal server error
|
||||||
|
- No custom field instance is created
|
||||||
|
"""
|
||||||
|
cf_date = CustomField.objects.create(
|
||||||
|
name="datefield",
|
||||||
|
data_type=CustomField.FieldDataType.DATE,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Doc",
|
||||||
|
checksum="123",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/documents/{doc.pk}/",
|
||||||
|
{
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": cf_date.pk,
|
||||||
|
"value": "10.03.2026",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("custom_fields", response.data)
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
@@ -1087,6 +1087,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data["all"]), 50)
|
self.assertEqual(len(response.data["all"]), 50)
|
||||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||||
|
|
||||||
|
def test_default_ordering_uses_id_as_tiebreaker(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Documents sharing the same created date
|
||||||
|
WHEN:
|
||||||
|
- API request for documents without an explicit ordering
|
||||||
|
THEN:
|
||||||
|
- Results are correctly ordered by created > id
|
||||||
|
"""
|
||||||
|
older_doc = Document.objects.create(
|
||||||
|
checksum="older",
|
||||||
|
content="older",
|
||||||
|
created=date(2024, 1, 1),
|
||||||
|
)
|
||||||
|
first_same_date_doc = Document.objects.create(
|
||||||
|
checksum="same-date-1",
|
||||||
|
content="same-date-1",
|
||||||
|
created=date(2024, 1, 2),
|
||||||
|
)
|
||||||
|
second_same_date_doc = Document.objects.create(
|
||||||
|
checksum="same-date-2",
|
||||||
|
content="same-date-2",
|
||||||
|
created=date(2024, 1, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
[result["id"] for result in response.data["results"]],
|
||||||
|
[
|
||||||
|
second_same_date_doc.id,
|
||||||
|
first_same_date_doc.id,
|
||||||
|
older_doc.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_statistics(self):
|
def test_statistics(self):
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
@@ -2953,6 +2990,58 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(create_resp.data["document"], doc.pk)
|
self.assertEqual(create_resp.data["document"], doc.pk)
|
||||||
|
|
||||||
|
def test_share_link_update_methods_not_allowed(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing share link
|
||||||
|
WHEN:
|
||||||
|
- PUT and PATCH requests are made to its detail endpoint
|
||||||
|
THEN:
|
||||||
|
- The API rejects them with 405 and the link is unchanged
|
||||||
|
"""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="share link content",
|
||||||
|
)
|
||||||
|
expiration = timezone.now() + timedelta(days=7)
|
||||||
|
create_resp = self.client.post(
|
||||||
|
"/api/share_links/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"expiration": expiration.isoformat(),
|
||||||
|
"file_version": ShareLink.FileVersion.ORIGINAL,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
share_link_id = create_resp.data["id"]
|
||||||
|
|
||||||
|
patch_resp = self.client.patch(
|
||||||
|
f"/api/share_links/{share_link_id}/",
|
||||||
|
data={
|
||||||
|
"expiration": None,
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(patch_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
put_resp = self.client.put(
|
||||||
|
f"/api/share_links/{share_link_id}/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"expiration": None,
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(put_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
share_link = ShareLink.objects.get(pk=share_link_id)
|
||||||
|
self.assertEqual(share_link.file_version, ShareLink.FileVersion.ORIGINAL)
|
||||||
|
self.assertIsNotNone(share_link.expiration)
|
||||||
|
|
||||||
def test_next_asn(self):
|
def test_next_asn(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -702,6 +702,40 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(correction, None)
|
self.assertEqual(correction, None)
|
||||||
|
|
||||||
|
def test_search_spelling_suggestion_suppressed_for_private_terms(self):
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
attacker = User.objects.create_user("attacker")
|
||||||
|
attacker.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
|
for i in range(55):
|
||||||
|
private_doc = Document.objects.create(
|
||||||
|
checksum=f"p{i}",
|
||||||
|
pk=100 + i,
|
||||||
|
title=f"Private Document {i + 1}",
|
||||||
|
content=f"treasury document {i + 1}",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
visible_doc = Document.objects.create(
|
||||||
|
checksum=f"v{i}",
|
||||||
|
pk=200 + i,
|
||||||
|
title=f"Visible Document {i + 1}",
|
||||||
|
content=f"public ledger {i + 1}",
|
||||||
|
owner=attacker,
|
||||||
|
)
|
||||||
|
index.update_document(writer, private_doc)
|
||||||
|
index.update_document(writer, visible_doc)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/?query=treasurx")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["count"], 0)
|
||||||
|
self.assertIsNone(response.data["corrected_query"])
|
||||||
|
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"whoosh.searching.Searcher.correct_query",
|
"whoosh.searching.Searcher.correct_query",
|
||||||
side_effect=Exception("Test error"),
|
side_effect=Exception("Test error"),
|
||||||
@@ -772,6 +806,58 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(results[0]["id"], d3.id)
|
self.assertEqual(results[0]["id"], d3.id)
|
||||||
self.assertEqual(results[1]["id"], d1.id)
|
self.assertEqual(results[1]["id"], d1.id)
|
||||||
|
|
||||||
|
def test_search_more_like_requires_view_permission_on_seed_document(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user can search documents they own
|
||||||
|
- Another user's private document exists with similar content
|
||||||
|
WHEN:
|
||||||
|
- The user requests more-like-this for the private seed document
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
attacker = User.objects.create_user("attacker")
|
||||||
|
attacker.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private_seed = Document.objects.create(
|
||||||
|
title="private bank statement",
|
||||||
|
content="quarterly treasury bank statement wire transfer",
|
||||||
|
checksum="seed",
|
||||||
|
owner=owner,
|
||||||
|
pk=10,
|
||||||
|
)
|
||||||
|
visible_doc = Document.objects.create(
|
||||||
|
title="attacker-visible match",
|
||||||
|
content="quarterly treasury bank statement wire transfer summary",
|
||||||
|
checksum="visible",
|
||||||
|
owner=attacker,
|
||||||
|
pk=11,
|
||||||
|
)
|
||||||
|
other_doc = Document.objects.create(
|
||||||
|
title="unrelated",
|
||||||
|
content="completely different topic",
|
||||||
|
checksum="other",
|
||||||
|
owner=attacker,
|
||||||
|
pk=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
|
index.update_document(writer, private_seed)
|
||||||
|
index.update_document(writer, visible_doc)
|
||||||
|
index.update_document(writer, other_doc)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?more_like_id={private_seed.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content, b"Insufficient permissions.")
|
||||||
|
|
||||||
def test_search_filtering(self):
|
def test_search_filtering(self):
|
||||||
t = Tag.objects.create(name="tag")
|
t = Tag.objects.create(name="tag")
|
||||||
t2 = Tag.objects.create(name="tag2")
|
t2 = Tag.objects.create(name="tag2")
|
||||||
|
|||||||
@@ -3512,6 +3512,124 @@ class TestWorkflows(
|
|||||||
as_json=False,
|
as_json=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||||
|
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
|
||||||
|
self,
|
||||||
|
mock_execute_webhook_action,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document updated workflow with only a webhook action
|
||||||
|
- A tag update that happens after run_workflows
|
||||||
|
WHEN:
|
||||||
|
- The workflow runs
|
||||||
|
THEN:
|
||||||
|
- The concurrent tag update is preserved
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
|
use_params=False,
|
||||||
|
body="Test message: {{doc_url}}",
|
||||||
|
url="http://paperless-ngx.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||||
|
webhook=webhook_action,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Webhook workflow",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
inbox_tag = Tag.objects.create(name="inbox")
|
||||||
|
error_tag = Tag.objects.create(name="error")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
doc.tags.add(inbox_tag)
|
||||||
|
|
||||||
|
def add_error_tag(*args, **kwargs):
|
||||||
|
Document.objects.get(pk=doc.pk).tags.add(error_tag)
|
||||||
|
|
||||||
|
mock_execute_webhook_action.side_effect = add_error_tag
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
|
||||||
|
|
||||||
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||||
|
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
|
||||||
|
self,
|
||||||
|
mock_execute_webhook_action,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document updated workflow that clears tags and assigns an inbox tag
|
||||||
|
- A later tag update that happens before the workflow finishes
|
||||||
|
WHEN:
|
||||||
|
- The workflow runs
|
||||||
|
THEN:
|
||||||
|
- The later tag update is preserved
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
removal_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
||||||
|
remove_all_tags=True,
|
||||||
|
)
|
||||||
|
assign_action = WorkflowAction.objects.create(
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
assign_action.assign_tags.add(self.t1)
|
||||||
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
|
use_params=False,
|
||||||
|
body="Test message: {{doc_url}}",
|
||||||
|
url="http://paperless-ngx.com",
|
||||||
|
include_document=False,
|
||||||
|
)
|
||||||
|
notify_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||||
|
webhook=webhook_action,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow tag race",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(removal_action)
|
||||||
|
w.actions.add(assign_action)
|
||||||
|
w.actions.add(notify_action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
owner=self.user3,
|
||||||
|
)
|
||||||
|
doc.tags.add(self.t2, self.t3)
|
||||||
|
|
||||||
|
def add_error_tag(*args, **kwargs):
|
||||||
|
Document.objects.get(pk=doc.pk).tags.add(self.t2)
|
||||||
|
|
||||||
|
mock_execute_webhook_action.side_effect = add_error_tag
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.owner, self.user2)
|
||||||
|
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
PAPERLESS_URL="http://localhost:8000",
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-4
@@ -49,6 +49,7 @@ from django.utils import timezone
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
@@ -70,10 +71,12 @@ from rest_framework import parsers
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.mixins import CreateModelMixin
|
||||||
from rest_framework.mixins import DestroyModelMixin
|
from rest_framework.mixins import DestroyModelMixin
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.mixins import RetrieveModelMixin
|
from rest_framework.mixins import RetrieveModelMixin
|
||||||
@@ -783,7 +786,7 @@ class DocumentViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Document.objects.distinct()
|
Document.objects.distinct()
|
||||||
.order_by("-created")
|
.order_by("-created", "-id")
|
||||||
.annotate(num_notes=Count("notes"))
|
.annotate(num_notes=Count("notes"))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related("tags", "custom_fields", "notes")
|
.prefetch_related("tags", "custom_fields", "notes")
|
||||||
@@ -1369,11 +1372,28 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
filtered_queryset = super().filter_queryset(queryset)
|
filtered_queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
if self._is_search_request():
|
if self._is_search_request():
|
||||||
from documents import index
|
|
||||||
|
|
||||||
if "query" in self.request.query_params:
|
if "query" in self.request.query_params:
|
||||||
|
from documents import index
|
||||||
|
|
||||||
query_class = index.DelayedFullTextQuery
|
query_class = index.DelayedFullTextQuery
|
||||||
elif "more_like_id" in self.request.query_params:
|
elif "more_like_id" in self.request.query_params:
|
||||||
|
try:
|
||||||
|
more_like_doc_id = int(self.request.query_params["more_like_id"])
|
||||||
|
more_like_doc = Document.objects.select_related("owner").get(
|
||||||
|
pk=more_like_doc_id,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, Document.DoesNotExist):
|
||||||
|
raise PermissionDenied(_("Invalid more_like_id"))
|
||||||
|
|
||||||
|
if not has_perms_owner_aware(
|
||||||
|
self.request.user,
|
||||||
|
"view_document",
|
||||||
|
more_like_doc,
|
||||||
|
):
|
||||||
|
raise PermissionDenied(_("Insufficient permissions."))
|
||||||
|
|
||||||
|
from documents import index
|
||||||
|
|
||||||
query_class = index.DelayedMoreLikeThisQuery
|
query_class = index.DelayedMoreLikeThisQuery
|
||||||
else:
|
else:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
@@ -1409,6 +1429,8 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
return response
|
return response
|
||||||
except NotFound:
|
except NotFound:
|
||||||
raise
|
raise
|
||||||
|
except PermissionDenied as e:
|
||||||
|
return HttpResponseForbidden(str(e.detail))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred listing search results: {e!s}")
|
logger.warning(f"An error occurred listing search results: {e!s}")
|
||||||
return HttpResponseBadRequest(
|
return HttpResponseBadRequest(
|
||||||
@@ -2681,7 +2703,14 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
class ShareLinkViewSet(
|
||||||
|
PassUserMixin,
|
||||||
|
CreateModelMixin,
|
||||||
|
RetrieveModelMixin,
|
||||||
|
DestroyModelMixin,
|
||||||
|
ListModelMixin,
|
||||||
|
GenericViewSet,
|
||||||
|
):
|
||||||
model = ShareLink
|
model = ShareLink
|
||||||
|
|
||||||
queryset = ShareLink.objects.all()
|
queryset = ShareLink.objects.all()
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations")
|
|||||||
def apply_assignment_to_document(
|
def apply_assignment_to_document(
|
||||||
action: WorkflowAction,
|
action: WorkflowAction,
|
||||||
document: Document,
|
document: Document,
|
||||||
doc_tag_ids: list[int],
|
|
||||||
logging_group,
|
logging_group,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -25,12 +24,7 @@ def apply_assignment_to_document(
|
|||||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
||||||
"""
|
"""
|
||||||
if action.has_assign_tags:
|
if action.has_assign_tags:
|
||||||
tag_ids_to_add: set[int] = set()
|
document.add_nested_tags(action.assign_tags.all())
|
||||||
for tag in action.assign_tags.all():
|
|
||||||
tag_ids_to_add.add(tag.pk)
|
|
||||||
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
|
||||||
|
|
||||||
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
|
||||||
|
|
||||||
if action.assign_correspondent:
|
if action.assign_correspondent:
|
||||||
document.correspondent = action.assign_correspondent
|
document.correspondent = action.assign_correspondent
|
||||||
@@ -197,7 +191,6 @@ def apply_assignment_to_overrides(
|
|||||||
def apply_removal_to_document(
|
def apply_removal_to_document(
|
||||||
action: WorkflowAction,
|
action: WorkflowAction,
|
||||||
document: Document,
|
document: Document,
|
||||||
doc_tag_ids: list[int],
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Apply removal actions to a Document instance.
|
Apply removal actions to a Document instance.
|
||||||
@@ -206,14 +199,15 @@ def apply_removal_to_document(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if action.remove_all_tags:
|
if action.remove_all_tags:
|
||||||
doc_tag_ids.clear()
|
document.tags.clear()
|
||||||
else:
|
else:
|
||||||
tag_ids_to_remove: set[int] = set()
|
tag_ids_to_remove: set[int] = set()
|
||||||
for tag in action.remove_tags.all():
|
for tag in action.remove_tags.all():
|
||||||
tag_ids_to_remove.add(tag.pk)
|
tag_ids_to_remove.add(tag.pk)
|
||||||
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
||||||
|
|
||||||
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
if tag_ids_to_remove:
|
||||||
|
document.tags.remove(*tag_ids_to_remove)
|
||||||
|
|
||||||
if action.remove_all_correspondents or (
|
if action.remove_all_correspondents or (
|
||||||
document.correspondent
|
document.correspondent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (2, 20, 12)
|
__version__: Final[tuple[int, int, int]] = (2, 20, 14)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
name=name,
|
name=name,
|
||||||
defaults={
|
defaults={
|
||||||
"match": name,
|
"match": name,
|
||||||
|
"matching_algorithm": Correspondent.MATCH_LITERAL,
|
||||||
},
|
},
|
||||||
)[0]
|
)[0]
|
||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.serialisers import CorrespondentField
|
from documents.serialisers import CorrespondentField
|
||||||
from documents.serialisers import DocumentTypeField
|
from documents.serialisers import DocumentTypeField
|
||||||
from documents.serialisers import OwnedObjectSerializer
|
from documents.serialisers import OwnedObjectSerializer
|
||||||
@@ -56,7 +60,18 @@ class MailAccountSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
class AccountField(serializers.PrimaryKeyRelatedField):
|
class AccountField(serializers.PrimaryKeyRelatedField):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return MailAccount.objects.all().order_by("-id")
|
user = getattr(self.context.get("request"), "user", None)
|
||||||
|
if user is None:
|
||||||
|
user = getattr(self.root, "user", None)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return MailAccount.objects.none()
|
||||||
|
|
||||||
|
return get_objects_for_user_owner_aware(
|
||||||
|
user,
|
||||||
|
"change_mailaccount",
|
||||||
|
MailAccount,
|
||||||
|
).order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class MailRuleSerializer(OwnedObjectSerializer):
|
class MailRuleSerializer(OwnedObjectSerializer):
|
||||||
@@ -127,6 +142,18 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
def validate_account(self, account):
|
||||||
|
if self.user is not None and has_perms_owner_aware(
|
||||||
|
self.user,
|
||||||
|
"change_mailaccount",
|
||||||
|
account,
|
||||||
|
):
|
||||||
|
return account
|
||||||
|
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Insufficient permissions."),
|
||||||
|
)
|
||||||
|
|
||||||
def validate_maximum_age(self, value):
|
def validate_maximum_age(self, value):
|
||||||
if value > 36500: # ~100 years
|
if value > 36500: # ~100 years
|
||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
|
|||||||
@@ -632,6 +632,132 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(returned_rule1.name, "Updated Name 1")
|
self.assertEqual(returned_rule1.name, "Updated Name 1")
|
||||||
self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE)
|
self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE)
|
||||||
|
|
||||||
|
def test_create_mail_rule_scopes_accounts(self):
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
missing_response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk + 1000,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(missing_response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data["account"][0].code, "does_not_exist")
|
||||||
|
self.assertEqual(missing_response.data["account"][0].code, "does_not_exist")
|
||||||
|
self.assertEqual(MailRule.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_create_mail_rule_allowed_for_granted_account_change_permission(self):
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
assign_perm("change_mailaccount", self.user, foreign_account)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"name": "Rule1",
|
||||||
|
"account": foreign_account.pk,
|
||||||
|
"folder": "INBOX",
|
||||||
|
"filter_from": "from@example.com",
|
||||||
|
"maximum_age": 30,
|
||||||
|
"action": MailRule.MailAction.MARK_READ,
|
||||||
|
"assign_title_from": MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
"assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
"order": 0,
|
||||||
|
"attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(MailRule.objects.get().account, foreign_account)
|
||||||
|
|
||||||
|
def test_update_mail_rule_forbidden_for_unpermitted_account(self):
|
||||||
|
own_account = MailAccount.objects.create(
|
||||||
|
name="Email1",
|
||||||
|
username="username1",
|
||||||
|
password="password1",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
)
|
||||||
|
other_user = User.objects.create_user(username="mail-owner")
|
||||||
|
foreign_account = MailAccount.objects.create(
|
||||||
|
name="ForeignEmail",
|
||||||
|
username="username2",
|
||||||
|
password="password2",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
character_set="UTF-8",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
rule1 = MailRule.objects.create(
|
||||||
|
name="Rule1",
|
||||||
|
account=own_account,
|
||||||
|
folder="INBOX",
|
||||||
|
filter_from="from@example.com",
|
||||||
|
maximum_age=30,
|
||||||
|
action=MailRule.MailAction.MARK_READ,
|
||||||
|
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||||
|
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||||
|
order=0,
|
||||||
|
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{rule1.pk}/",
|
||||||
|
data={"account": foreign_account.pk},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
rule1.refresh_from_db()
|
||||||
|
self.assertEqual(rule1.account, own_account)
|
||||||
|
|
||||||
def test_get_mail_rules_owner_aware(self):
|
def test_get_mail_rules_owner_aware(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ class TestMail(
|
|||||||
c = handler._get_correspondent(message, rule)
|
c = handler._get_correspondent(message, rule)
|
||||||
self.assertIsNotNone(c)
|
self.assertIsNotNone(c)
|
||||||
self.assertEqual(c.name, "someone@somewhere.com")
|
self.assertEqual(c.name, "someone@somewhere.com")
|
||||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY)
|
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL)
|
||||||
self.assertEqual(c.match, "someone@somewhere.com")
|
self.assertEqual(c.match, "someone@somewhere.com")
|
||||||
c = handler._get_correspondent(message2, rule)
|
c = handler._get_correspondent(message2, rule)
|
||||||
self.assertIsNotNone(c)
|
self.assertIsNotNone(c)
|
||||||
|
|||||||
@@ -1991,7 +1991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.12"
|
version = "2.20.14"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in new issue
Block a user