Merge remote-tracking branch 'origin/dev'

This commit is contained in:
Trenton H 2023-08-24 11:35:16 -07:00
commit 8165071edf
20 changed files with 600 additions and 169 deletions

View File

@ -589,6 +589,12 @@ case, Paperless will remove the staging copy as well as the scan, and give you a
message asking you to restart the process from scratch, by scanning the odd pages again,
followed by the even pages.
It's important that the scan files get consumed in the correct order, and one at a time.
You therefore need to make sure that Paperless is running while you upload the files into
the directory; and if you're using [polling](/configuration#polling), make sure that
`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear,
like 5-10 or even lower.
Another thing that might happen is that you start a double sided scan, but then forget
to upload the second file. To avoid collating the wrong documents if you then come back
a day later to scan a new double-sided document, Paperless will only keep an "odd numbered
@ -597,11 +603,11 @@ scan a completely new "odd numbered pages" one. The old staging file will get di
### Interaction with "subdirs as tags"
The collation feature can be used together with the "subdirs as tags" feature (but this is not
a requirement). Just create a correctly named double-sided subdir in the hierachy and upload
your scans there. For example, both `double-sided/foo/bar` as well as `foo/bar/double-sided` will
cause the collated document to be treated as if it were uploaded into `foo/bar` and receive both
`foo` and `bar` tags, but not `double-sided`.
The collation feature can be used together with the [subdirs as tags](/configuration#consume_config)
feature (but this is not a requirement). Just create a correctly named double-sided subdir
in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as
well as `foo/bar/double-sided` will cause the collated document to be treated as if it
were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
### Interaction with document splitting

View File

@ -35,6 +35,12 @@ matcher.
Defaults to `redis://localhost:6379`.
`PAPERLESS_REDIS_PREFIX=<prefix>`
: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances.
Defaults to no prefix.
### Database
`PAPERLESS_DBENGINE=<engine_name>`
@ -495,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'`
Settings this value has security implications. Read the Django documentation
and be sure you understand its usage before setting it.
`PAPERLESS_EMAIL_CERTIFICATE_FILE=<path>`
: Configures an additional SSL certificate file containing a [certificate](https://docs.python.org/3/library/ssl.html#certificates)
or certificate chain which should be trusted for validating SSL connections against mail providers.
This is for use with self-signed certificates against local IMAP servers.
Defaults to None.
!!! warning
Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)

View File

@ -723,7 +723,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">600</context>
<context context-type="linenumber">648</context>
</context-group>
</trans-unit>
<trans-unit id="2526035785704676448" datatype="html">
@ -2913,19 +2913,19 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">711</context>
<context context-type="linenumber">759</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">771</context>
<context context-type="linenumber">819</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">838</context>
<context context-type="linenumber">886</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">901</context>
<context context-type="linenumber">949</context>
</context-group>
</trans-unit>
<trans-unit id="1181910457994920507" datatype="html">
@ -2940,19 +2940,19 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">713</context>
<context context-type="linenumber">761</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">773</context>
<context context-type="linenumber">821</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">840</context>
<context context-type="linenumber">888</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">903</context>
<context context-type="linenumber">951</context>
</context-group>
</trans-unit>
<trans-unit id="5729001209753056399" datatype="html">
@ -4489,235 +4489,263 @@
<context context-type="linenumber">372</context>
</context-group>
</trans-unit>
<trans-unit id="3066660568529853846" datatype="html">
<source>Error retrieving groups</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">278</context>
</context-group>
</trans-unit>
<trans-unit id="1235706724900303689" datatype="html">
<source>Error retrieving users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">287</context>
</context-group>
</trans-unit>
<trans-unit id="5241231471117657636" datatype="html">
<source>Error retrieving mail rules</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">314</context>
</context-group>
</trans-unit>
<trans-unit id="3178554336792037159" datatype="html">
<source>Error retrieving mail accounts</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">323</context>
</context-group>
</trans-unit>
<trans-unit id="5610279464668232148" datatype="html">
<source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">482</context>
<context context-type="linenumber">530</context>
</context-group>
</trans-unit>
<trans-unit id="3891152409365583719" datatype="html">
<source>Settings saved</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">584</context>
<context context-type="linenumber">632</context>
</context-group>
</trans-unit>
<trans-unit id="7217000812750597833" datatype="html">
<source>Settings were saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">585</context>
<context context-type="linenumber">633</context>
</context-group>
</trans-unit>
<trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">589</context>
<context context-type="linenumber">637</context>
</context-group>
</trans-unit>
<trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">590</context>
<context context-type="linenumber">638</context>
</context-group>
</trans-unit>
<trans-unit id="6839066544204061364" datatype="html">
<source>Use system language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">609</context>
<context context-type="linenumber">657</context>
</context-group>
</trans-unit>
<trans-unit id="7729897675462249787" datatype="html">
<source>Use date format of display language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">616</context>
<context context-type="linenumber">664</context>
</context-group>
</trans-unit>
<trans-unit id="5260584511980773458" datatype="html">
<source>Error while storing settings on server.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">636</context>
<context context-type="linenumber">684</context>
</context-group>
</trans-unit>
<trans-unit id="4510369340305901516" datatype="html">
<source>Password has been changed, you will be logged out momentarily.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">679</context>
<context context-type="linenumber">727</context>
</context-group>
</trans-unit>
<trans-unit id="2753185112875184719" datatype="html">
<source>Saved user &quot;<x id="PH" equiv-text="newUser.username"/>&quot;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">686</context>
<context context-type="linenumber">734</context>
</context-group>
</trans-unit>
<trans-unit id="3471101514724661554" datatype="html">
<source>Error saving user.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">698</context>
<context context-type="linenumber">746</context>
</context-group>
</trans-unit>
<trans-unit id="5565868288871970148" datatype="html">
<source>Confirm delete user account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">709</context>
<context context-type="linenumber">757</context>
</context-group>
</trans-unit>
<trans-unit id="8133663925694885325" datatype="html">
<source>This operation will permanently delete this user account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">710</context>
<context context-type="linenumber">758</context>
</context-group>
</trans-unit>
<trans-unit id="857903183180440990" datatype="html">
<source>Deleted user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">719</context>
<context context-type="linenumber">767</context>
</context-group>
</trans-unit>
<trans-unit id="1942566571910298572" datatype="html">
<source>Error deleting user.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">727</context>
<context context-type="linenumber">775</context>
</context-group>
</trans-unit>
<trans-unit id="5766640174051730159" datatype="html">
<source>Saved group &quot;<x id="PH" equiv-text="newGroup.name"/>&quot;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">748</context>
<context context-type="linenumber">796</context>
</context-group>
</trans-unit>
<trans-unit id="8382042988405122578" datatype="html">
<source>Error saving group.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">758</context>
<context context-type="linenumber">806</context>
</context-group>
</trans-unit>
<trans-unit id="6538873300613683004" datatype="html">
<source>Confirm delete user group</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">769</context>
<context context-type="linenumber">817</context>
</context-group>
</trans-unit>
<trans-unit id="7710984639498518244" datatype="html">
<source>This operation will permanently delete this user group.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">770</context>
<context context-type="linenumber">818</context>
</context-group>
</trans-unit>
<trans-unit id="6834066329827670963" datatype="html">
<source>Deleted group</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">779</context>
<context context-type="linenumber">827</context>
</context-group>
</trans-unit>
<trans-unit id="8850738980935204840" datatype="html">
<source>Error deleting group.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">787</context>
<context context-type="linenumber">835</context>
</context-group>
</trans-unit>
<trans-unit id="6327501535846658797" datatype="html">
<source>Saved account &quot;<x id="PH" equiv-text="newMailAccount.name"/>&quot;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">813</context>
<context context-type="linenumber">861</context>
</context-group>
</trans-unit>
<trans-unit id="8067594003836508139" datatype="html">
<source>Error saving account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">825</context>
<context context-type="linenumber">873</context>
</context-group>
</trans-unit>
<trans-unit id="5641934153807844674" datatype="html">
<source>Confirm delete mail account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">836</context>
<context context-type="linenumber">884</context>
</context-group>
</trans-unit>
<trans-unit id="7176985344323395435" datatype="html">
<source>This operation will permanently delete this mail account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">837</context>
<context context-type="linenumber">885</context>
</context-group>
</trans-unit>
<trans-unit id="4233826387148482123" datatype="html">
<source>Deleted mail account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">846</context>
<context context-type="linenumber">894</context>
</context-group>
</trans-unit>
<trans-unit id="6202503362522392111" datatype="html">
<source>Error deleting mail account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">855</context>
<context context-type="linenumber">903</context>
</context-group>
</trans-unit>
<trans-unit id="123368655395433699" datatype="html">
<source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">876</context>
<context context-type="linenumber">924</context>
</context-group>
</trans-unit>
<trans-unit id="8951124554918814321" datatype="html">
<source>Error saving rule.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">888</context>
<context context-type="linenumber">936</context>
</context-group>
</trans-unit>
<trans-unit id="3896080636020672118" datatype="html">
<source>Confirm delete mail rule</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">899</context>
<context context-type="linenumber">947</context>
</context-group>
</trans-unit>
<trans-unit id="2250372580580310337" datatype="html">
<source>This operation will permanently delete this mail rule.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">900</context>
<context context-type="linenumber">948</context>
</context-group>
</trans-unit>
<trans-unit id="9077981247971516916" datatype="html">
<source>Deleted mail rule</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">909</context>
<context context-type="linenumber">957</context>
</context-group>
</trans-unit>
<trans-unit id="2033194641751367552" datatype="html">
<source>Error deleting mail rule.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">918</context>
<context context-type="linenumber">966</context>
</context-group>
</trans-unit>
<trans-unit id="5101757640976222639" datatype="html">

View File

@ -2,7 +2,7 @@
<label class="form-label" for="tags" i18n>Tags</label>
<div class="input-group flex-nowrap">
<ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="true"
[closeOnSelect]="false"
@ -11,11 +11,7 @@
[addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag"
i18n-addTagText
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
(change)="onChange(value)">
<ng-template ng-label-tmp let-item="item">
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">

View File

@ -15,16 +15,28 @@ import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
} from 'src/app/data/matching-model'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { RouterTestingModule } from '@angular/router/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs'
import { TagService } from 'src/app/services/rest/tag.service'
import {
NgbAccordionModule,
NgbModal,
NgbModalModule,
NgbModalRef,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { CheckComponent } from '../check/check.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { TextComponent } from '../text/text.component'
import { ColorComponent } from '../color/color.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../select/select.component'
import { ColorSliderModule } from 'ngx-color/slider'
import { By } from '@angular/platform-browser'
const tags: PaperlessTag[] = [
{
@ -56,12 +68,32 @@ describe('TagsComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TagsComponent],
declarations: [
TagsComponent,
TagEditDialogComponent,
TextComponent,
ColorComponent,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
ColorComponent,
CheckComponent,
],
providers: [
{
provide: TagService,
useValue: {
listAll: () => of(tags),
listAll: () =>
of({
results: tags,
}),
create: () =>
of({
name: 'bar',
id: 99,
color: '#fff000',
}),
},
},
],
@ -72,6 +104,8 @@ describe('TagsComponent', () => {
RouterTestingModule,
HttpClientTestingModule,
NgbModalModule,
NgbAccordionModule,
NgbPopoverModule,
],
}).compileComponents()
@ -85,7 +119,7 @@ describe('TagsComponent', () => {
})
it('should support suggestions', () => {
expect(component.value).toBeUndefined()
expect(component.value).toHaveLength(0)
component.value = []
component.tags = tags
component.suggestions = [1, 2]
@ -107,19 +141,19 @@ describe('TagsComponent', () => {
it('should support create new using last search term and open a modal', () => {
let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.onSearch({ term: 'bar' })
component.select.searchTerm = 'foobar'
component.createTag()
expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('bar')
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
const editDialog = activeInstances[0]
.componentInstance as TagEditDialogComponent
editDialog.save() // create is mocked
fixture.detectChanges()
fixture.whenStable().then(() => {
expect(fixture.debugElement.nativeElement.textContent).toContain('foobar')
})
})
it('should clear search term on blur after delay', fakeAsync(() => {
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
component.onBlur()
tick(3000)
expect(clearSpy).toHaveBeenCalled()
}))
it('support remove tags', () => {
component.tags = tags
component.value = [1, 2]
@ -132,6 +166,7 @@ describe('TagsComponent', () => {
})
it('should get tags', () => {
component.tags = null
expect(component.getTag(2)).toBeNull()
component.tags = tags
expect(component.getTag(2)).toEqual(tags[1])

View File

@ -5,6 +5,7 @@ import {
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@ -12,6 +13,8 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { TagService } from 'src/app/services/rest/tag.service'
import { EditDialogMode } from '../../edit-dialog/edit-dialog.component'
import { first, firstValueFrom, tap } from 'rxjs'
import { NgSelectComponent } from '@ng-select/ng-select'
@Component({
providers: [
@ -74,14 +77,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Output()
filterDocuments = new EventEmitter<PaperlessTag[]>()
value: number[]
@ViewChild('tagSelect') select: NgSelectComponent
tags: PaperlessTag[]
value: number[] = []
tags: PaperlessTag[] = []
public createTagRef: (name) => void
private _lastSearchTerm: string
getTag(id: number) {
if (this.tags) {
return this.tags.find((tag) => tag.id == id)
@ -111,15 +114,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (name) modal.componentInstance.object = { name: name }
else if (this._lastSearchTerm)
modal.componentInstance.object = { name: this._lastSearchTerm }
modal.componentInstance.succeeded.subscribe((newTag) => {
this.tagService.listAll().subscribe((tags) => {
this.tags = tags.results
this.value = [...this.value, newTag.id]
this.onChange(this.value)
})
})
else if (this.select.searchTerm)
modal.componentInstance.object = { name: this.select.searchTerm }
this.select.searchTerm = null
this.select.detectChanges()
return firstValueFrom(
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
first(),
tap(() => {
this.tagService.listAll().subscribe((tags) => {
this.tags = tags.results
})
})
)
)
}
getSuggestions() {
@ -137,20 +145,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
this.onChange(this.value)
}
clearLastSearchTerm() {
this._lastSearchTerm = null
}
onSearch($event) {
this._lastSearchTerm = $event.term
}
onBlur() {
setTimeout(() => {
this.clearLastSearchTerm()
}, 3000)
}
get hasPrivate(): boolean {
return this.value.some(
(t) => this.tags?.find((t2) => t2.id === t) === undefined

View File

@ -243,7 +243,7 @@
<ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }">
<h4>
<ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()">
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
@ -262,7 +262,7 @@
<li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col">
<div class="btn-group">
@ -280,7 +280,7 @@
<ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()">
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
@ -299,7 +299,7 @@
<li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col">
<div class="btn-group">
@ -323,7 +323,7 @@
</ng-template>
</li>
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)">
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)">
<a ngbNavLink i18n>Users & Groups</a>
<ng-template ngbNavContent>
@ -334,7 +334,7 @@
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add User</ng-container>
<ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" i18n>Add User</ng-container>
</button>
</h4>
<ul class="list-group" formGroupName="usersGroup">
@ -350,13 +350,13 @@
<li *ngFor="let user of users" class="list-group-item" [formGroupName]="user.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)">{{user.username}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col">
<div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" i18n>Delete</button>
<button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }" i18n>Delete</button>
</div>
</div>
</div>
@ -369,7 +369,7 @@
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Group</ng-container>
<ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }" i18n>Add Group</ng-container>
</button>
</h4>
<ul *ngIf="groups.length > 0" class="list-group" formGroupName="groupsGroup">
@ -385,13 +385,13 @@
<li *ngFor="let group of groups" class="list-group-item" [formGroupName]="group.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)">{{group.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
<div class="col"></div>
<div class="col"></div>
<div class="col">
<div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" i18n>Delete</button>
<button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }" i18n>Delete</button>
</div>
</div>
</div>

View File

@ -15,6 +15,7 @@ import {
NgbModule,
NgbNavLink,
NgbModalRef,
NgbAlertModule,
} from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
@ -42,6 +43,13 @@ import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SettingsComponent } from './settings.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { PasswordComponent } from '../../common/input/password/password.component'
import { NumberComponent } from '../../common/input/number/number.component'
import { TagsComponent } from '../../common/input/tags/tags.component'
import { NgSelectModule } from '@ng-select/ng-select'
const savedViews = [
{ id: 1, name: 'view1' },
@ -90,6 +98,14 @@ describe('SettingsComponent', () => {
ConfirmDialogComponent,
CheckComponent,
ColorComponent,
SafeHtmlPipe,
SelectComponent,
TextComponent,
PasswordComponent,
NumberComponent,
TagsComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@ -98,6 +114,8 @@ describe('SettingsComponent', () => {
RouterTestingModule.withRoutes(routes),
FormsModule,
ReactiveFormsModule,
NgbAlertModule,
NgSelectModule,
],
}).compileComponents()
@ -116,52 +134,66 @@ describe('SettingsComponent', () => {
jest
.spyOn(permissionsService, 'currentUserOwnsObject')
.mockReturnValue(true)
jest.spyOn(userService, 'listAll').mockReturnValue(
of({
all: users.map((u) => u.id),
count: users.length,
results: users.concat([]),
})
)
groupService = TestBed.inject(GroupService)
jest.spyOn(groupService, 'listAll').mockReturnValue(
of({
all: groups.map((g) => g.id),
count: groups.length,
results: groups.concat([]),
})
)
savedViewService = TestBed.inject(SavedViewService)
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
of({
all: savedViews.map((v) => v.id),
count: savedViews.length,
results: (savedViews as PaperlessSavedView[]).concat([]),
})
)
mailAccountService = TestBed.inject(MailAccountService)
jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
of({
all: mailAccounts.map((a) => a.id),
count: mailAccounts.length,
results: (mailAccounts as PaperlessMailAccount[]).concat([]),
})
)
mailRuleService = TestBed.inject(MailRuleService)
jest.spyOn(mailRuleService, 'listAll').mockReturnValue(
of({
all: mailRules.map((r) => r.id),
count: mailRules.length,
results: (mailRules as PaperlessMailRule[]).concat([]),
})
)
})
function completeSetup(excludeService = null) {
if (excludeService !== userService) {
jest.spyOn(userService, 'listAll').mockReturnValue(
of({
all: users.map((u) => u.id),
count: users.length,
results: users.concat([]),
})
)
}
if (excludeService !== groupService) {
jest.spyOn(groupService, 'listAll').mockReturnValue(
of({
all: groups.map((g) => g.id),
count: groups.length,
results: groups.concat([]),
})
)
}
if (excludeService !== savedViewService) {
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
of({
all: savedViews.map((v) => v.id),
count: savedViews.length,
results: (savedViews as PaperlessSavedView[]).concat([]),
})
)
}
if (excludeService !== mailAccountService) {
jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
of({
all: mailAccounts.map((a) => a.id),
count: mailAccounts.length,
results: (mailAccounts as PaperlessMailAccount[]).concat([]),
})
)
}
if (excludeService !== mailRuleService) {
jest.spyOn(mailRuleService, 'listAll').mockReturnValue(
of({
all: mailRules.map((r) => r.id),
count: mailRules.length,
results: (mailRules as PaperlessMailRule[]).concat([]),
})
)
}
fixture = TestBed.createComponent(SettingsComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
}
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
@ -187,6 +219,7 @@ describe('SettingsComponent', () => {
})
it('should support direct link to tab by URL, scroll if needed', () => {
completeSetup()
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ section: 'mail' })))
@ -199,6 +232,7 @@ describe('SettingsComponent', () => {
})
it('should lazy load tab data', () => {
completeSetup()
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
expect(component.savedViews).toBeUndefined()
@ -221,6 +255,7 @@ describe('SettingsComponent', () => {
})
it('should support save saved views, show error', () => {
completeSetup()
component.maybeInitializeTab(3) // SavedViews
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@ -248,6 +283,7 @@ describe('SettingsComponent', () => {
})
it('should support save local settings updating appearance settings and calling API, show error', () => {
completeSetup()
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show')
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
@ -275,6 +311,7 @@ describe('SettingsComponent', () => {
})
it('should offer reload if settings changes require', () => {
completeSetup()
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
component.initialize(true) // reset
@ -288,6 +325,7 @@ describe('SettingsComponent', () => {
})
it('should allow setting theme color, visually apply change immediately but not save', () => {
completeSetup()
const appearanceSpy = jest.spyOn(
settingsService,
'updateAppearanceSettings'
@ -304,6 +342,7 @@ describe('SettingsComponent', () => {
})
it('should support delete saved view', () => {
completeSetup()
component.maybeInitializeTab(3) // SavedViews
const toastSpy = jest.spyOn(toastService, 'showInfo')
const deleteSpy = jest.spyOn(savedViewService, 'delete')
@ -316,6 +355,7 @@ describe('SettingsComponent', () => {
})
it('should support edit / create user, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
@ -332,6 +372,7 @@ describe('SettingsComponent', () => {
})
it('should support delete user, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteUser(users[0])
@ -352,6 +393,7 @@ describe('SettingsComponent', () => {
})
it('should logout current user if password changed, after delay', fakeAsync(() => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
@ -371,6 +413,7 @@ describe('SettingsComponent', () => {
}))
it('should support edit / create group, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editGroup(groups[0])
@ -386,6 +429,7 @@ describe('SettingsComponent', () => {
})
it('should support delete group, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteGroup(users[0])
@ -406,12 +450,71 @@ describe('SettingsComponent', () => {
})
it('should get group name', () => {
completeSetup()
component.maybeInitializeTab(5) // UsersGroups
expect(component.getGroupName(1)).toEqual(groups[0].name)
expect(component.getGroupName(11)).toEqual('')
})
it('should show errors on load if load mailAccounts failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailAccountService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail accounts'))
)
completeSetup(mailAccountService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load mailRules failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailRuleService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail rules'))
)
completeSetup(mailRuleService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
// tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load users failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(userService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load users'))
)
completeSetup(userService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load groups failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(groupService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load groups'))
)
completeSetup(groupService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should support edit / create mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailAccount(mailAccounts[0] as PaperlessMailAccount)
@ -427,6 +530,7 @@ describe('SettingsComponent', () => {
})
it('should support delete mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount)
@ -447,6 +551,7 @@ describe('SettingsComponent', () => {
})
it('should support edit / create mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailRule(mailRules[0] as PaperlessMailRule)
@ -462,6 +567,7 @@ describe('SettingsComponent', () => {
})
it('should support delete mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailRule(mailRules[0] as PaperlessMailRule)

View File

@ -146,7 +146,7 @@ export class SettingsComponent
private groupsService: GroupService,
private router: Router,
private modalService: NgbModal,
private permissionsService: PermissionsService
public permissionsService: PermissionsService
) {
super()
this.settings.settingsSaved.subscribe(() => {
@ -259,25 +259,73 @@ export class SettingsComponent
navID == SettingsNavIDs.UsersGroups &&
(!this.users || !this.groups)
) {
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.initialize(false)
this.usersService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.users = r.results
this.groupsService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.groups = r.results
this.initialize(false)
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving groups`,
10000,
JSON.stringify(e)
)
},
})
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving users`,
10000,
JSON.stringify(e)
)
},
})
})
} else if (
navID == SettingsNavIDs.Mail &&
(!this.mailAccounts || !this.mailRules)
) {
this.mailAccountService.listAll().subscribe((r) => {
this.mailAccounts = r.results
this.mailAccountService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.mailAccounts = r.results
this.mailRuleService.listAll().subscribe((r) => {
this.mailRules = r.results
this.initialize(false)
this.mailRuleService
.listAll()
.pipe(first())
.subscribe({
next: (r) => {
this.mailRules = r.results
this.initialize(false)
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail rules`,
10000,
JSON.stringify(e)
)
},
})
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail accounts`,
10000,
JSON.stringify(e)
)
},
})
})
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { Subject, zip } from 'rxjs'
import { Subject } from 'rxjs'
export interface Toast {
title: string

View File

@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '3',
appTitle: 'Paperless-ngx',
version: '1.17.1',
version: '1.17.1-dev',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@ -80,6 +80,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
.btn {
--bs-btn-disabled-opacity: 0.35;
}
.btn.btn-link {
--bs-btn-disabled-opacity: 0.85;
}
.btn-primary {
&:hover, &:focus, &.active, &:active {

View File

@ -3453,6 +3453,110 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_insufficient_permissions_ownership(self, m):
"""
GIVEN:
- Documents owned by user other than logged in user
WHEN:
- set_permissions bulk edit API endpoint is called
THEN:
- User is not able to change permissions
"""
m.return_value = "OK"
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1)
permissions = {
"owner": user1.id,
}
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
self.assertEqual(response.content, b"Insufficient permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_insufficient_permissions_edit(self, m):
"""
GIVEN:
- Documents for which current user only has view permissions
WHEN:
- API is called
THEN:
- set_storage_path is only called if user can edit all docs
"""
m.return_value = "OK"
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
assign_perm("view_document", user1, self.doc1)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
self.assertEqual(response.content, b"Insufficient permissions")
assign_perm("change_document", user1, self.doc1)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
class TestBulkDownload(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/documents/bulk_download/"

View File

@ -54,6 +54,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.viewsets import ViewSet
from documents import bulk_edit
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessObjectPermissions
@ -694,7 +695,7 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
serializer.save(owner=self.request.user)
class BulkEditView(GenericAPIView):
class BulkEditView(GenericAPIView, PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = BulkEditSerializer
parser_classes = (parsers.JSONParser,)
@ -703,10 +704,25 @@ class BulkEditView(GenericAPIView):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = self.request.user
method = serializer.validated_data.get("method")
parameters = serializer.validated_data.get("parameters")
documents = serializer.validated_data.get("documents")
if not user.is_superuser:
document_objs = Document.objects.filter(pk__in=documents)
has_perms = (
all((doc.owner == user or doc.owner is None) for doc in document_objs)
if method == bulk_edit.set_permissions
else all(
has_perms_owner_aware(user, "change_document", doc)
for doc in document_objs
)
)
if not has_perms:
return HttpResponseForbidden("Insufficient permissions")
try:
# TODO: parameter validation
result = method(documents, **parameters)

View File

@ -177,6 +177,23 @@ def settings_values_check(app_configs, **kwargs):
)
return msgs
def _email_certificate_validate():
msgs = []
# Existence checks
if (
settings.EMAIL_CERTIFICATE_FILE is not None
and not settings.EMAIL_CERTIFICATE_FILE.is_file()
):
msgs.append(
Error(
f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file",
),
)
return msgs
return (
_ocrmypdf_settings_check() + _timezone_validate() + _barcode_scanner_validate()
_ocrmypdf_settings_check()
+ _timezone_validate()
+ _barcode_scanner_validate()
+ _email_certificate_validate()
)

View File

@ -67,11 +67,20 @@ def __get_float(key: str, default: float) -> float:
return float(os.getenv(key, default))
def __get_path(key: str, default: Union[PathLike, str]) -> Path:
def __get_path(
key: str,
default: Optional[Union[PathLike, str]] = None,
) -> Optional[Path]:
"""
Return a normalized, absolute path based on the environment variable or a default
Return a normalized, absolute path based on the environment variable or a default,
if provided. If not set and no default, returns None
"""
return Path(os.environ.get(key, default)).resolve()
if key in os.environ:
return Path(os.environ[key]).resolve()
elif default is not None:
return Path(default).resolve()
else:
return None
def __get_list(
@ -364,6 +373,7 @@ CHANNEL_LAYERS = {
"hosts": [_CHANNELS_REDIS_URL],
"capacity": 2000, # default 100
"expiry": 15, # default 60
"prefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
},
},
}
@ -476,6 +486,8 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken"
SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
EMAIL_CERTIFICATE_FILE = __get_path("PAPERLESS_EMAIL_CERTIFICATE_FILE")
###############################################################################
# Database #
@ -679,6 +691,9 @@ CELERY_TASK_SEND_SENT_EVENT = True
CELERY_SEND_TASK_SENT_EVENT = True
CELERY_BROKER_CONNECTION_RETRY = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BROKER_TRANSPORT_OPTIONS = {
"global_keyprefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""),
}
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800)

View File

@ -1,9 +1,11 @@
import os
from pathlib import Path
from django.test import TestCase
from django.test import override_settings
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import binaries_check
from paperless.checks import debug_mode_check
from paperless.checks import paths_check
@ -57,7 +59,7 @@ class TestChecks(DirectoriesMixin, TestCase):
self.assertEqual(len(debug_mode_check(None)), 1)
class TestSettingsChecks(DirectoriesMixin, TestCase):
class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
def test_all_valid(self):
"""
GIVEN:
@ -70,6 +72,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0)
class TestOcrSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(OCR_OUTPUT_TYPE="notapdf")
def test_invalid_output_type(self):
"""
@ -160,6 +164,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
self.assertIn('OCR clean mode "cleanme"', msg.msg)
class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(TIME_ZONE="TheMoon\\MyCrater")
def test_invalid_timezone(self):
"""
@ -178,6 +184,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
class TestBarcodeSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(CONSUMER_BARCODE_SCANNER="Invalid")
def test_barcode_scanner_invalid(self):
msgs = settings_values_check(None)
@ -200,3 +208,26 @@ class TestSettingsChecks(DirectoriesMixin, TestCase):
def test_barcode_scanner_valid(self):
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0)
class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem"))
def test_not_valid_file(self):
"""
GIVEN:
- Default settings
- Email certificate is set
WHEN:
- Email certificate file doesn't exist
THEN:
- system check error reported for email certificate
"""
self.assertIsNotFile("/tmp/not_actually_here.pem")
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)

View File

@ -395,12 +395,16 @@ def get_mailbox(server, port, security) -> MailBox:
"""
Returns the correct MailBox instance for the given configuration.
"""
ssl_context = ssl.create_default_context()
if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover
ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE)
if security == MailAccount.ImapSecurity.NONE:
mailbox = MailBoxUnencrypted(server, port)
elif security == MailAccount.ImapSecurity.STARTTLS:
mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context())
mailbox = MailBoxTls(server, port, ssl_context=ssl_context)
elif security == MailAccount.ImapSecurity.SSL:
mailbox = MailBox(server, port, ssl_context=ssl.create_default_context())
mailbox = MailBox(server, port, ssl_context=ssl_context)
else:
raise NotImplementedError("Unknown IMAP security") # pragma: nocover
return mailbox

View File

@ -215,7 +215,11 @@ class MailDocumentParser(DocumentParser):
file_multi_part[2],
)
response = httpx.post(url_merge, files=pdf_collection, timeout=30.0)
response = httpx.post(
url_merge,
files=pdf_collection,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses
archive_path.write_bytes(response.content)
@ -330,7 +334,7 @@ class MailDocumentParser(DocumentParser):
files=files,
headers=headers,
data=data,
timeout=30.0,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses
except Exception as err:
@ -409,7 +413,12 @@ class MailDocumentParser(DocumentParser):
file_multi_part[2],
)
response = httpx.post(url, files=files, data=data, timeout=30.0)
response = httpx.post(
url,
files=files,
data=data,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses
except Exception as err:
raise ParseError(f"Error while converting document to PDF: {err}") from err

View File

@ -100,7 +100,7 @@ class TikaDocumentParser(DocumentParser):
files=files,
headers=headers,
data=data,
timeout=30.0,
timeout=settings.CELERY_TASK_TIME_LIMIT,
)
response.raise_for_status() # ensure we notice bad responses
except Exception as err: