Merge branch 'dev' into feature-4063-oauth-email
This commit is contained in:
commit
ef850f7e03
@ -568,7 +568,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">29</context>
|
||||
<context context-type="linenumber">79</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
|
||||
@ -700,6 +700,10 @@
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">50</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
|
||||
<context context-type="linenumber">51</context>
|
||||
@ -1680,7 +1684,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
<context context-type="linenumber">78</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
|
||||
@ -3500,7 +3504,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">14</context>
|
||||
<context context-type="linenumber">64</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
|
||||
@ -3519,7 +3523,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
<context context-type="linenumber">66</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
|
||||
@ -3538,7 +3542,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
<context context-type="linenumber">69</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
|
||||
@ -4126,39 +4130,72 @@
|
||||
<context context-type="linenumber">42</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6625768491622252297" datatype="html">
|
||||
<source>e.g.</source>
|
||||
<trans-unit id="2816147949408898105" datatype="html">
|
||||
<source>See <a target='_blank' href='https://docs.paperless-ngx.com/advanced_usage/#file-name-handling'>the documentation</a>.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">13</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1918584360573970155" datatype="html">
|
||||
<source>or use slashes to add directories e.g.</source>
|
||||
<trans-unit id="1295614462098694869" datatype="html">
|
||||
<source>Preview</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">18</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">282</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8057014866157903311" datatype="html">
|
||||
<source>Path test failed</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7871464228487558644" datatype="html">
|
||||
<source>See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.</source>
|
||||
<trans-unit id="9116034231465034307" datatype="html">
|
||||
<source>No document selected</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5676637575587497817" datatype="html">
|
||||
<source>Search for documents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
|
||||
<context context-type="linenumber">53</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6423278459497515329" datatype="html">
|
||||
<source>No documents found</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6898961890896270754" datatype="html">
|
||||
<source>Create new storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
|
||||
<context context-type="linenumber">37</context>
|
||||
<context context-type="linenumber">63</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3754859110054016570" datatype="html">
|
||||
<source>Edit storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
|
||||
<context context-type="linenumber">41</context>
|
||||
<context context-type="linenumber">67</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9011959596901584887" datatype="html">
|
||||
@ -4836,20 +4873,6 @@
|
||||
<context context-type="linenumber">14</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6423278459497515329" datatype="html">
|
||||
<source>No documents found</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5676637575587497817" datatype="html">
|
||||
<source>Search for documents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
|
||||
<context context-type="linenumber">53</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8627133593113147800" datatype="html">
|
||||
<source>Selected items</source>
|
||||
<context-group purpose="location">
|
||||
@ -6120,13 +6143,6 @@
|
||||
<context context-type="linenumber">275</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1295614462098694869" datatype="html">
|
||||
<source>Preview</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">282</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7206723502037428235" datatype="html">
|
||||
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||
<context-group purpose="location">
|
||||
|
@ -10,7 +10,57 @@
|
||||
<div class="modal-body">
|
||||
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||
<pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint" [monospace]="true"></pngx-input-textarea>
|
||||
<pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" hint="See <a target='_blank' href='https://docs.paperless-ngx.com/advanced_usage/#file-name-handling'>the documentation</a>." i18n-hint [monospace]="true"></pngx-input-textarea>
|
||||
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton i18n>Preview</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="card mb-2">
|
||||
<div class="card-body p-2">
|
||||
@if (testLoading) {
|
||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||
} @else if (testResult) {
|
||||
<code>{{testResult}}</code>
|
||||
} @else if (testFailed) {
|
||||
<div class="text-danger" i18n>Path test failed</div>
|
||||
} @else {
|
||||
<div class="text-muted small" i18n>No document selected</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<ng-select name="testDocument"
|
||||
[items]="foundDocuments$ | async"
|
||||
placeholder="Search for a document" i18n-placeholder
|
||||
notFoundText="No documents found" i18n-notFoundText
|
||||
bindValue="id"
|
||||
bindLabel="title"
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="loading"
|
||||
[typeahead]="documentsInput$"
|
||||
(change)="testPath($event)">
|
||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
|
||||
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
|
@ -0,0 +1,4 @@
|
||||
.accordion {
|
||||
--bs-accordion-btn-padding-x: 0.75rem;
|
||||
--bs-accordion-btn-padding-y: 0.375rem;
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
NgbAccordionButton,
|
||||
NgbActiveModal,
|
||||
NgbModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
@ -14,10 +18,16 @@ import { TextAreaComponent } from '../../input/textarea/textarea.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { By } from '@angular/platform-browser'
|
||||
|
||||
describe('StoragePathEditDialogComponent', () => {
|
||||
let component: StoragePathEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let documentService: DocumentService
|
||||
let fixture: ComponentFixture<StoragePathEditDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -40,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
@ -59,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
expect(editTitleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support test path', () => {
|
||||
const testSpy = jest.spyOn(
|
||||
component['service'] as StoragePathService,
|
||||
'testPath'
|
||||
)
|
||||
testSpy.mockReturnValueOnce(of('test/abc123'))
|
||||
component.objectForm.patchValue({ path: 'test/{{title}}' })
|
||||
fixture.detectChanges()
|
||||
component.testPath({ id: 1 })
|
||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||
expect(component.testResult).toBe('test/abc123')
|
||||
expect(component.testFailed).toBeFalsy()
|
||||
|
||||
// test failed
|
||||
testSpy.mockReturnValueOnce(of(''))
|
||||
component.testPath({ id: 1 })
|
||||
expect(component.testResult).toBeNull()
|
||||
expect(component.testFailed).toBeTruthy()
|
||||
|
||||
component.testPath(null)
|
||||
expect(component.testResult).toBeNull()
|
||||
})
|
||||
|
||||
it('should compare two documents by id', () => {
|
||||
const doc1 = { id: 1 }
|
||||
const doc2 = { id: 2 }
|
||||
expect(component.compareDocuments(doc1, doc1)).toBeTruthy()
|
||||
expect(component.compareDocuments(doc1, doc2)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should use id as trackBy', () => {
|
||||
expect(component.trackByFn({ id: 1 })).toBe(1)
|
||||
})
|
||||
|
||||
it('should search on select text input', () => {
|
||||
fixture.debugElement
|
||||
.query(By.directive(NgbAccordionButton))
|
||||
.triggerEventHandler('click', null)
|
||||
fixture.detectChanges()
|
||||
const documents = [
|
||||
{ id: 1, title: 'foo' },
|
||||
{ id: 2, title: 'bar' },
|
||||
]
|
||||
const listSpy = jest.spyOn(documentService, 'listFiltered')
|
||||
listSpy.mockReturnValueOnce(
|
||||
of({
|
||||
count: 1,
|
||||
results: documents[0],
|
||||
all: [1],
|
||||
} as any)
|
||||
)
|
||||
component.documentsInput$.next('bar')
|
||||
expect(listSpy).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
'created',
|
||||
true,
|
||||
[{ rule_type: FILTER_TITLE, value: 'bar' }],
|
||||
{ truncate_content: true }
|
||||
)
|
||||
listSpy.mockReturnValueOnce(
|
||||
of({
|
||||
count: 2,
|
||||
results: [...documents],
|
||||
all: [1, 2],
|
||||
} as any)
|
||||
)
|
||||
component.documentsInput$.next('ba')
|
||||
listSpy.mockReturnValueOnce(throwError(() => new Error()))
|
||||
component.documentsInput$.next('foo')
|
||||
})
|
||||
|
||||
it('should run path test on path change', () => {
|
||||
const testSpy = jest.spyOn(component, 'testPath')
|
||||
component['testDocument'] = { id: 1 } as any
|
||||
component.objectForm.patchValue(
|
||||
{ path: 'test/{{title}}' },
|
||||
{ emitEvent: true }
|
||||
)
|
||||
fixture.detectChanges()
|
||||
expect(testSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@ -1,9 +1,25 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Component, OnDestroy } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
Subject,
|
||||
Observable,
|
||||
concat,
|
||||
of,
|
||||
distinctUntilChanged,
|
||||
takeUntil,
|
||||
tap,
|
||||
switchMap,
|
||||
map,
|
||||
catchError,
|
||||
filter,
|
||||
} from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@ -13,24 +29,34 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
templateUrl: './storage-path-edit-dialog.component.html',
|
||||
styleUrls: ['./storage-path-edit-dialog.component.scss'],
|
||||
})
|
||||
export class StoragePathEditDialogComponent extends EditDialogComponent<StoragePath> {
|
||||
export class StoragePathEditDialogComponent
|
||||
extends EditDialogComponent<StoragePath>
|
||||
implements OnDestroy
|
||||
{
|
||||
public documentsInput$ = new Subject<string>()
|
||||
public foundDocuments$: Observable<Document[]>
|
||||
private testDocument: Document
|
||||
public testResult: string
|
||||
public testFailed: boolean = false
|
||||
public loading = false
|
||||
public testLoading = false
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
service: StoragePathService,
|
||||
activeModal: NgbActiveModal,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService
|
||||
settingsService: SettingsService,
|
||||
private documentsService: DocumentService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
this.initPathObservables()
|
||||
}
|
||||
|
||||
get pathHint() {
|
||||
return (
|
||||
$localize`e.g.` +
|
||||
' <code class="text-nowrap">{{ created_year }}-{{ title }}</code> ' +
|
||||
$localize`or use slashes to add directories e.g.` +
|
||||
' <code class="text-nowrap">{{ created_year }}/{{ title }}</code>. ' +
|
||||
$localize`See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.`
|
||||
)
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
@ -51,4 +77,71 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
|
||||
permissions_form: new FormControl(null),
|
||||
})
|
||||
}
|
||||
|
||||
public testPath(document: Document) {
|
||||
if (!document) {
|
||||
this.testResult = null
|
||||
return
|
||||
}
|
||||
this.testDocument = document
|
||||
this.testLoading = true
|
||||
;(this.service as StoragePathService)
|
||||
.testPath(this.objectForm.get('path').value, document.id)
|
||||
.subscribe((result) => {
|
||||
if (result?.length) {
|
||||
this.testResult = result
|
||||
this.testFailed = false
|
||||
} else {
|
||||
this.testResult = null
|
||||
this.testFailed = true
|
||||
}
|
||||
this.testLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
compareDocuments(document: Document, selectedDocument: Document) {
|
||||
return document.id === selectedDocument.id
|
||||
}
|
||||
|
||||
private initPathObservables() {
|
||||
this.objectForm
|
||||
.get('path')
|
||||
.valueChanges.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
filter((path) => path && !!this.testDocument)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.testPath(this.testDocument)
|
||||
})
|
||||
|
||||
this.foundDocuments$ = concat(
|
||||
of([]), // default items
|
||||
this.documentsInput$.pipe(
|
||||
tap(() => console.log('searching')),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap(() => (this.loading = true)),
|
||||
switchMap((title) =>
|
||||
this.documentsService
|
||||
.listFiltered(
|
||||
1,
|
||||
null,
|
||||
'created',
|
||||
true,
|
||||
[{ rule_type: FILTER_TITLE, value: title }],
|
||||
{ truncate_content: true }
|
||||
)
|
||||
.pipe(
|
||||
map((result) => result.results),
|
||||
catchError(() => of([])), // empty on error
|
||||
tap(() => (this.loading = false))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
trackByFn(item: Document) {
|
||||
return item.id
|
||||
}
|
||||
}
|
||||
|
@ -3,3 +3,7 @@
|
||||
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
(change)="onChange(value)"
|
||||
[disabled]="disabled"
|
||||
[placeholder]="placeholder"
|
||||
rows="6">
|
||||
rows="4">
|
||||
</textarea>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
|
@ -1,7 +1,35 @@
|
||||
import { StoragePathService } from './storage-path.service'
|
||||
import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: StoragePathService
|
||||
let subscription: Subscription
|
||||
const endpoint = 'storage_paths'
|
||||
|
||||
commonAbstractNameFilterPaperlessServiceTests(
|
||||
'storage_paths',
|
||||
StoragePathService
|
||||
)
|
||||
|
||||
describe(`Additional service tests for StoragePathservice`, () => {
|
||||
beforeEach(() => {
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(StoragePathService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subscription?.unsubscribe()
|
||||
httpTestingController.verify()
|
||||
})
|
||||
|
||||
it('should support testing path', () => {
|
||||
subscription = service.testPath('path', 11).subscribe()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}${endpoint}/test/`)
|
||||
.flush('ok')
|
||||
})
|
||||
})
|
||||
|
@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -10,4 +11,11 @@ export class StoragePathService extends AbstractNameFilterService<StoragePath> {
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'storage_paths')
|
||||
}
|
||||
|
||||
public testPath(path: string, documentID: number): Observable<any> {
|
||||
return this.http.post<string>(`${this.getResourceUrl()}test/`, {
|
||||
path,
|
||||
document: documentID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,8 @@ $form-file-button-hover-bg: var(--pngx-bg-alt);
|
||||
|
||||
// Paperless-ngx styles
|
||||
body {
|
||||
font-size: 0.875rem;
|
||||
--pngx-body-font-size: 0.875rem;
|
||||
font-size: var(--pngx-body-font-size);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@ -653,6 +654,10 @@ code {
|
||||
filter: invert(0.5) saturate(0);
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
font-size: var(--pngx-body-font-size);
|
||||
}
|
||||
|
||||
.me-1px {
|
||||
margin-right: 1px !important;
|
||||
}
|
||||
|
@ -12,8 +12,8 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import add_inbox_tags
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflow_added
|
||||
from documents.signals.handlers import run_workflow_updated
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
from documents.signals.handlers import run_workflows_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_log_entry
|
||||
@ -27,7 +27,7 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(set_storage_path)
|
||||
document_consumption_finished.connect(set_log_entry)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(run_workflow_added)
|
||||
document_updated.connect(run_workflow_updated)
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
|
||||
AppConfig.ready(self)
|
||||
|
@ -4,7 +4,6 @@ import os
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import magic
|
||||
from django.conf import settings
|
||||
@ -21,7 +20,6 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.matching import document_matches_workflow
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -30,8 +28,6 @@ from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
@ -46,6 +42,8 @@ from documents.plugins.helpers import ProgressManager
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.signals import document_consumption_started
|
||||
from documents.signals.handlers import run_workflows
|
||||
from documents.templating.title import parse_doc_title_w_placeholders
|
||||
from documents.utils import copy_basic_file_stats
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.utils import run_subprocess
|
||||
@ -63,162 +61,13 @@ class WorkflowTriggerPlugin(
|
||||
"""
|
||||
Get overrides from matching workflows
|
||||
"""
|
||||
msg = ""
|
||||
overrides = DocumentMetadataOverrides()
|
||||
for workflow in (
|
||||
Workflow.objects.filter(enabled=True)
|
||||
.prefetch_related("actions")
|
||||
.prefetch_related("actions__assign_view_users")
|
||||
.prefetch_related("actions__assign_view_groups")
|
||||
.prefetch_related("actions__assign_change_users")
|
||||
.prefetch_related("actions__assign_change_groups")
|
||||
.prefetch_related("actions__assign_custom_fields")
|
||||
.prefetch_related("actions__remove_tags")
|
||||
.prefetch_related("actions__remove_correspondents")
|
||||
.prefetch_related("actions__remove_document_types")
|
||||
.prefetch_related("actions__remove_storage_paths")
|
||||
.prefetch_related("actions__remove_custom_fields")
|
||||
.prefetch_related("actions__remove_owners")
|
||||
.prefetch_related("triggers")
|
||||
.order_by("order")
|
||||
):
|
||||
action_overrides = DocumentMetadataOverrides()
|
||||
|
||||
if document_matches_workflow(
|
||||
self.input_doc,
|
||||
workflow,
|
||||
overrides, msg = run_workflows(
|
||||
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
):
|
||||
for action in workflow.actions.all():
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(action, WorkflowAction)
|
||||
msg += f"Applying {action} from {workflow}\n"
|
||||
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
|
||||
if action.assign_title is not None:
|
||||
action_overrides.title = action.assign_title
|
||||
if action.assign_tags is not None:
|
||||
action_overrides.tag_ids = list(
|
||||
action.assign_tags.values_list("pk", flat=True),
|
||||
self.input_doc,
|
||||
None,
|
||||
DocumentMetadataOverrides(),
|
||||
)
|
||||
|
||||
if action.assign_correspondent is not None:
|
||||
action_overrides.correspondent_id = (
|
||||
action.assign_correspondent.pk
|
||||
)
|
||||
if action.assign_document_type is not None:
|
||||
action_overrides.document_type_id = (
|
||||
action.assign_document_type.pk
|
||||
)
|
||||
if action.assign_storage_path is not None:
|
||||
action_overrides.storage_path_id = (
|
||||
action.assign_storage_path.pk
|
||||
)
|
||||
if action.assign_owner is not None:
|
||||
action_overrides.owner_id = action.assign_owner.pk
|
||||
if action.assign_view_users is not None:
|
||||
action_overrides.view_users = list(
|
||||
action.assign_view_users.values_list("pk", flat=True),
|
||||
)
|
||||
if action.assign_view_groups is not None:
|
||||
action_overrides.view_groups = list(
|
||||
action.assign_view_groups.values_list("pk", flat=True),
|
||||
)
|
||||
if action.assign_change_users is not None:
|
||||
action_overrides.change_users = list(
|
||||
action.assign_change_users.values_list("pk", flat=True),
|
||||
)
|
||||
if action.assign_change_groups is not None:
|
||||
action_overrides.change_groups = list(
|
||||
action.assign_change_groups.values_list(
|
||||
"pk",
|
||||
flat=True,
|
||||
),
|
||||
)
|
||||
if action.assign_custom_fields is not None:
|
||||
action_overrides.custom_field_ids = list(
|
||||
action.assign_custom_fields.values_list(
|
||||
"pk",
|
||||
flat=True,
|
||||
),
|
||||
)
|
||||
overrides.update(action_overrides)
|
||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||
# Removal actions overwrite the current overrides
|
||||
if action.remove_all_tags:
|
||||
overrides.tag_ids = []
|
||||
elif overrides.tag_ids:
|
||||
for tag in action.remove_custom_fields.filter(
|
||||
pk__in=overrides.tag_ids,
|
||||
):
|
||||
overrides.tag_ids.remove(tag.pk)
|
||||
|
||||
if action.remove_all_correspondents or (
|
||||
overrides.correspondent_id is not None
|
||||
and action.remove_correspondents.filter(
|
||||
pk=overrides.correspondent_id,
|
||||
).exists()
|
||||
):
|
||||
overrides.correspondent_id = None
|
||||
|
||||
if action.remove_all_document_types or (
|
||||
overrides.document_type_id is not None
|
||||
and action.remove_document_types.filter(
|
||||
pk=overrides.document_type_id,
|
||||
).exists()
|
||||
):
|
||||
overrides.document_type_id = None
|
||||
|
||||
if action.remove_all_storage_paths or (
|
||||
overrides.storage_path_id is not None
|
||||
and action.remove_storage_paths.filter(
|
||||
pk=overrides.storage_path_id,
|
||||
).exists()
|
||||
):
|
||||
overrides.storage_path_id = None
|
||||
|
||||
if action.remove_all_custom_fields:
|
||||
overrides.custom_field_ids = []
|
||||
elif overrides.custom_field_ids:
|
||||
for field in action.remove_custom_fields.filter(
|
||||
pk__in=overrides.custom_field_ids,
|
||||
):
|
||||
overrides.custom_field_ids.remove(field.pk)
|
||||
|
||||
if action.remove_all_owners or (
|
||||
overrides.owner_id is not None
|
||||
and action.remove_owners.filter(
|
||||
pk=overrides.owner_id,
|
||||
).exists()
|
||||
):
|
||||
overrides.owner_id = None
|
||||
|
||||
if action.remove_all_permissions:
|
||||
overrides.view_users = []
|
||||
overrides.view_groups = []
|
||||
overrides.change_users = []
|
||||
overrides.change_groups = []
|
||||
else:
|
||||
if overrides.view_users:
|
||||
for user in action.remove_view_users.filter(
|
||||
pk__in=overrides.view_users,
|
||||
):
|
||||
overrides.view_users.remove(user.pk)
|
||||
if overrides.change_users:
|
||||
for user in action.remove_change_users.filter(
|
||||
pk__in=overrides.change_users,
|
||||
):
|
||||
overrides.change_users.remove(user.pk)
|
||||
if overrides.view_groups:
|
||||
for user in action.remove_view_groups.filter(
|
||||
pk__in=overrides.view_groups,
|
||||
):
|
||||
overrides.view_groups.remove(user.pk)
|
||||
if overrides.change_groups:
|
||||
for user in action.remove_change_groups.filter(
|
||||
pk__in=overrides.change_groups,
|
||||
):
|
||||
overrides.change_groups.remove(user.pk)
|
||||
|
||||
if overrides:
|
||||
self.metadata.update(overrides)
|
||||
return msg
|
||||
|
||||
@ -348,7 +197,7 @@ class ConsumerPlugin(
|
||||
"""
|
||||
Check that if override_asn is given, it is unique and within a valid range
|
||||
"""
|
||||
if not self.metadata.asn:
|
||||
if self.metadata.asn is None:
|
||||
# check not necessary in case no ASN gets set
|
||||
return
|
||||
# Validate the range is above zero and less than uint32_t max
|
||||
@ -905,7 +754,7 @@ class ConsumerPlugin(
|
||||
pk=self.metadata.storage_path_id,
|
||||
)
|
||||
|
||||
if self.metadata.asn:
|
||||
if self.metadata.asn is not None:
|
||||
document.archive_serial_number = self.metadata.asn
|
||||
|
||||
if self.metadata.owner_id:
|
||||
@ -948,47 +797,3 @@ class ConsumerPlugin(
|
||||
copy_basic_file_stats(source, target)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
def parse_doc_title_w_placeholders(
|
||||
title: str,
|
||||
correspondent_name: str,
|
||||
doc_type_name: str,
|
||||
owner_username: str,
|
||||
local_added: datetime.datetime,
|
||||
original_filename: str,
|
||||
created: datetime.datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Available title placeholders for Workflows depend on what has already been assigned,
|
||||
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
||||
for added / updated triggers
|
||||
"""
|
||||
formatting = {
|
||||
"correspondent": correspondent_name,
|
||||
"document_type": doc_type_name,
|
||||
"added": local_added.isoformat(),
|
||||
"added_year": local_added.strftime("%Y"),
|
||||
"added_year_short": local_added.strftime("%y"),
|
||||
"added_month": local_added.strftime("%m"),
|
||||
"added_month_name": local_added.strftime("%B"),
|
||||
"added_month_name_short": local_added.strftime("%b"),
|
||||
"added_day": local_added.strftime("%d"),
|
||||
"added_time": local_added.strftime("%H:%M"),
|
||||
"owner_username": owner_username,
|
||||
"original_filename": Path(original_filename).stem,
|
||||
}
|
||||
if created is not None:
|
||||
formatting.update(
|
||||
{
|
||||
"created": created.isoformat(),
|
||||
"created_year": created.strftime("%Y"),
|
||||
"created_year_short": created.strftime("%y"),
|
||||
"created_month": created.strftime("%m"),
|
||||
"created_month_name": created.strftime("%B"),
|
||||
"created_month_name_short": created.strftime("%b"),
|
||||
"created_day": created.strftime("%d"),
|
||||
"created_time": created.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
return title.format(**formatting).strip()
|
||||
|
@ -2010,3 +2010,18 @@ class TrashSerializer(SerializerWithPerms):
|
||||
"Some documents in the list have not yet been deleted.",
|
||||
)
|
||||
return documents
|
||||
|
||||
|
||||
class StoragePathTestSerializer(SerializerWithPerms):
|
||||
path = serializers.CharField(
|
||||
required=True,
|
||||
label="Path",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
document = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Document.objects.all(),
|
||||
required=True,
|
||||
label="Document",
|
||||
write_only=True,
|
||||
)
|
||||
|
@ -24,7 +24,8 @@ from guardian.shortcuts import remove_perm
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.consumer import parse_doc_title_w_placeholders
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_unique_filename
|
||||
@ -38,6 +39,7 @@ from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.templating.title import parse_doc_title_w_placeholders
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
|
||||
@ -511,66 +513,81 @@ def add_to_index(sender, document, **kwargs):
|
||||
index.add_or_update_document(document)
|
||||
|
||||
|
||||
def run_workflow_added(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflow(
|
||||
def run_workflows_added(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflows(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
document,
|
||||
logging_group,
|
||||
)
|
||||
|
||||
|
||||
def run_workflow_updated(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflow(
|
||||
def run_workflows_updated(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflows(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
document,
|
||||
logging_group,
|
||||
)
|
||||
|
||||
|
||||
def run_workflow(
|
||||
def run_workflows(
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
document: Document,
|
||||
document: Document | ConsumableDocument,
|
||||
logging_group=None,
|
||||
):
|
||||
overrides: DocumentMetadataOverrides | None = None,
|
||||
) -> tuple[DocumentMetadataOverrides, str] | None:
|
||||
"""Run workflows which match a Document (or ConsumableDocument) for a specific trigger type.
|
||||
|
||||
Assignment or removal actions are either applied directly to the document or an overrides object. If an overrides
|
||||
object is provided, the function returns the object with the applied changes or None if no actions were applied and a string
|
||||
of messages for each action. If no overrides object is provided, the changes are applied directly to the document and the
|
||||
function returns None.
|
||||
"""
|
||||
|
||||
def assignment_action():
|
||||
if action.assign_tags.all().count() > 0:
|
||||
doc_tag_ids.extend(
|
||||
list(action.assign_tags.all().values_list("pk", flat=True)),
|
||||
if action.assign_tags.exists():
|
||||
if not use_overrides:
|
||||
doc_tag_ids.extend(action.assign_tags.values_list("pk", flat=True))
|
||||
else:
|
||||
if overrides.tag_ids is None:
|
||||
overrides.tag_ids = []
|
||||
overrides.tag_ids.extend(
|
||||
action.assign_tags.values_list("pk", flat=True),
|
||||
)
|
||||
|
||||
if action.assign_correspondent is not None:
|
||||
if action.assign_correspondent:
|
||||
if not use_overrides:
|
||||
document.correspondent = action.assign_correspondent
|
||||
else:
|
||||
overrides.correspondent_id = action.assign_correspondent.pk
|
||||
|
||||
if action.assign_document_type is not None:
|
||||
if action.assign_document_type:
|
||||
if not use_overrides:
|
||||
document.document_type = action.assign_document_type
|
||||
else:
|
||||
overrides.document_type_id = action.assign_document_type.pk
|
||||
|
||||
if action.assign_storage_path is not None:
|
||||
if action.assign_storage_path:
|
||||
if not use_overrides:
|
||||
document.storage_path = action.assign_storage_path
|
||||
else:
|
||||
overrides.storage_path_id = action.assign_storage_path.pk
|
||||
|
||||
if action.assign_owner is not None:
|
||||
if action.assign_owner:
|
||||
if not use_overrides:
|
||||
document.owner = action.assign_owner
|
||||
else:
|
||||
overrides.owner_id = action.assign_owner.pk
|
||||
|
||||
if action.assign_title is not None:
|
||||
if action.assign_title:
|
||||
if not use_overrides:
|
||||
try:
|
||||
document.title = parse_doc_title_w_placeholders(
|
||||
action.assign_title,
|
||||
(
|
||||
document.correspondent.name
|
||||
if document.correspondent is not None
|
||||
else ""
|
||||
),
|
||||
(
|
||||
document.document_type.name
|
||||
if document.document_type is not None
|
||||
else ""
|
||||
),
|
||||
(document.owner.username if document.owner is not None else ""),
|
||||
document.correspondent.name if document.correspondent else "",
|
||||
document.document_type.name if document.document_type else "",
|
||||
document.owner.username if document.owner else "",
|
||||
timezone.localtime(document.added),
|
||||
(
|
||||
document.original_filename
|
||||
if document.original_filename is not None
|
||||
else ""
|
||||
),
|
||||
document.original_filename or "",
|
||||
timezone.localtime(document.created),
|
||||
)
|
||||
except Exception:
|
||||
@ -578,135 +595,202 @@ def run_workflow(
|
||||
f"Error occurred parsing title assignment '{action.assign_title}', falling back to original",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
else:
|
||||
overrides.title = action.assign_title
|
||||
|
||||
if (
|
||||
(
|
||||
action.assign_view_users is not None
|
||||
and action.assign_view_users.count() > 0
|
||||
)
|
||||
or (
|
||||
action.assign_view_groups is not None
|
||||
and action.assign_view_groups.count() > 0
|
||||
)
|
||||
or (
|
||||
action.assign_change_users is not None
|
||||
and action.assign_change_users.count() > 0
|
||||
)
|
||||
or (
|
||||
action.assign_change_groups is not None
|
||||
and action.assign_change_groups.count() > 0
|
||||
)
|
||||
if any(
|
||||
[
|
||||
action.assign_view_users.exists(),
|
||||
action.assign_view_groups.exists(),
|
||||
action.assign_change_users.exists(),
|
||||
action.assign_change_groups.exists(),
|
||||
],
|
||||
):
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": action.assign_view_users.all().values_list(
|
||||
"id",
|
||||
)
|
||||
or [],
|
||||
"groups": action.assign_view_groups.all().values_list(
|
||||
"id",
|
||||
)
|
||||
or [],
|
||||
"users": action.assign_view_users.values_list("id", flat=True),
|
||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||
},
|
||||
"change": {
|
||||
"users": action.assign_change_users.all().values_list(
|
||||
"id",
|
||||
)
|
||||
or [],
|
||||
"groups": action.assign_change_groups.all().values_list(
|
||||
"id",
|
||||
)
|
||||
or [],
|
||||
"users": action.assign_change_users.values_list("id", flat=True),
|
||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||
},
|
||||
}
|
||||
if not use_overrides:
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=True,
|
||||
)
|
||||
else:
|
||||
overrides.view_users = list(
|
||||
set(
|
||||
(overrides.view_users or [])
|
||||
+ list(permissions["view"]["users"]),
|
||||
),
|
||||
)
|
||||
overrides.view_groups = list(
|
||||
set(
|
||||
(overrides.view_groups or [])
|
||||
+ list(permissions["view"]["groups"]),
|
||||
),
|
||||
)
|
||||
overrides.change_users = list(
|
||||
set(
|
||||
(overrides.change_users or [])
|
||||
+ list(permissions["change"]["users"]),
|
||||
),
|
||||
)
|
||||
overrides.change_groups = list(
|
||||
set(
|
||||
(overrides.change_groups or [])
|
||||
+ list(permissions["change"]["groups"]),
|
||||
),
|
||||
)
|
||||
|
||||
if action.assign_custom_fields is not None:
|
||||
if action.assign_custom_fields.exists():
|
||||
if not use_overrides:
|
||||
for field in action.assign_custom_fields.all():
|
||||
if (
|
||||
CustomFieldInstance.objects.filter(
|
||||
if not CustomFieldInstance.objects.filter(
|
||||
field=field,
|
||||
document=document,
|
||||
).count()
|
||||
== 0
|
||||
):
|
||||
).exists():
|
||||
# can be triggered on existing docs, so only add the field if it doesn't already exist
|
||||
CustomFieldInstance.objects.create(
|
||||
field=field,
|
||||
document=document,
|
||||
)
|
||||
else:
|
||||
overrides.custom_field_ids = list(
|
||||
set(
|
||||
(overrides.custom_field_ids or [])
|
||||
+ list(
|
||||
action.assign_custom_fields.values_list("pk", flat=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def removal_action():
|
||||
if action.remove_all_tags:
|
||||
if not use_overrides:
|
||||
doc_tag_ids.clear()
|
||||
else:
|
||||
overrides.tag_ids = None
|
||||
else:
|
||||
if not use_overrides:
|
||||
for tag in action.remove_tags.filter(
|
||||
pk__in=list(document.tags.values_list("pk", flat=True)),
|
||||
).all():
|
||||
pk__in=document.tags.values_list("pk", flat=True),
|
||||
):
|
||||
doc_tag_ids.remove(tag.pk)
|
||||
elif overrides.tag_ids:
|
||||
for tag in action.remove_tags.filter(pk__in=overrides.tag_ids):
|
||||
overrides.tag_ids.remove(tag.pk)
|
||||
|
||||
if action.remove_all_correspondents or (
|
||||
if not use_overrides and (
|
||||
action.remove_all_correspondents
|
||||
or (
|
||||
document.correspondent
|
||||
and (
|
||||
action.remove_correspondents.filter(
|
||||
and action.remove_correspondents.filter(
|
||||
pk=document.correspondent.pk,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
document.correspondent = None
|
||||
elif use_overrides and (
|
||||
action.remove_all_correspondents
|
||||
or (
|
||||
overrides.correspondent_id
|
||||
and action.remove_correspondents.filter(
|
||||
pk=overrides.correspondent_id,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
overrides.correspondent_id = None
|
||||
|
||||
if action.remove_all_document_types or (
|
||||
if not use_overrides and (
|
||||
action.remove_all_document_types
|
||||
or (
|
||||
document.document_type
|
||||
and (
|
||||
action.remove_document_types.filter(
|
||||
and action.remove_document_types.filter(
|
||||
pk=document.document_type.pk,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
document.document_type = None
|
||||
elif use_overrides and (
|
||||
action.remove_all_document_types
|
||||
or (
|
||||
overrides.document_type_id
|
||||
and action.remove_document_types.filter(
|
||||
pk=overrides.document_type_id,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
overrides.document_type_id = None
|
||||
|
||||
if action.remove_all_storage_paths or (
|
||||
if not use_overrides and (
|
||||
action.remove_all_storage_paths
|
||||
or (
|
||||
document.storage_path
|
||||
and (
|
||||
action.remove_storage_paths.filter(
|
||||
and action.remove_storage_paths.filter(
|
||||
pk=document.storage_path.pk,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
document.storage_path = None
|
||||
elif use_overrides and (
|
||||
action.remove_all_storage_paths
|
||||
or (
|
||||
overrides.storage_path_id
|
||||
and action.remove_storage_paths.filter(
|
||||
pk=overrides.storage_path_id,
|
||||
).exists()
|
||||
)
|
||||
):
|
||||
overrides.storage_path_id = None
|
||||
|
||||
if action.remove_all_owners or (
|
||||
if not use_overrides and (
|
||||
action.remove_all_owners
|
||||
or (
|
||||
document.owner
|
||||
and (action.remove_owners.filter(pk=document.owner.pk).exists())
|
||||
and action.remove_owners.filter(pk=document.owner.pk).exists()
|
||||
)
|
||||
):
|
||||
document.owner = None
|
||||
elif use_overrides and (
|
||||
action.remove_all_owners
|
||||
or (
|
||||
overrides.owner_id
|
||||
and action.remove_owners.filter(pk=overrides.owner_id).exists()
|
||||
)
|
||||
):
|
||||
overrides.owner_id = None
|
||||
|
||||
if action.remove_all_permissions:
|
||||
if not use_overrides:
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [],
|
||||
"groups": [],
|
||||
},
|
||||
"change": {
|
||||
"users": [],
|
||||
"groups": [],
|
||||
},
|
||||
"view": {"users": [], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
}
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=False,
|
||||
)
|
||||
elif (
|
||||
(action.remove_view_users.all().count() > 0)
|
||||
or (action.remove_view_groups.all().count() > 0)
|
||||
or (action.remove_change_users.all().count() > 0)
|
||||
or (action.remove_change_groups.all().count() > 0)
|
||||
else:
|
||||
overrides.view_users = None
|
||||
overrides.view_groups = None
|
||||
overrides.change_users = None
|
||||
overrides.change_groups = None
|
||||
elif any(
|
||||
[
|
||||
action.remove_view_users.exists(),
|
||||
action.remove_view_groups.exists(),
|
||||
action.remove_change_users.exists(),
|
||||
action.remove_change_groups.exists(),
|
||||
],
|
||||
):
|
||||
if not use_overrides:
|
||||
for user in action.remove_view_users.all():
|
||||
remove_perm("view_document", user, document)
|
||||
for user in action.remove_change_users.all():
|
||||
@ -715,62 +799,98 @@ def run_workflow(
|
||||
remove_perm("view_document", group, document)
|
||||
for group in action.remove_change_groups.all():
|
||||
remove_perm("change_document", group, document)
|
||||
else:
|
||||
if overrides.view_users:
|
||||
for user in action.remove_view_users.filter(
|
||||
pk__in=overrides.view_users,
|
||||
):
|
||||
overrides.view_users.remove(user.pk)
|
||||
if overrides.change_users:
|
||||
for user in action.remove_change_users.filter(
|
||||
pk__in=overrides.change_users,
|
||||
):
|
||||
overrides.change_users.remove(user.pk)
|
||||
if overrides.view_groups:
|
||||
for group in action.remove_view_groups.filter(
|
||||
pk__in=overrides.view_groups,
|
||||
):
|
||||
overrides.view_groups.remove(group.pk)
|
||||
if overrides.change_groups:
|
||||
for group in action.remove_change_groups.filter(
|
||||
pk__in=overrides.change_groups,
|
||||
):
|
||||
overrides.change_groups.remove(group.pk)
|
||||
|
||||
if action.remove_all_custom_fields:
|
||||
if not use_overrides:
|
||||
CustomFieldInstance.objects.filter(document=document).delete()
|
||||
elif action.remove_custom_fields.all().count() > 0:
|
||||
else:
|
||||
overrides.custom_field_ids = None
|
||||
elif action.remove_custom_fields.exists():
|
||||
if not use_overrides:
|
||||
CustomFieldInstance.objects.filter(
|
||||
field__in=action.remove_custom_fields.all(),
|
||||
document=document,
|
||||
).delete()
|
||||
|
||||
for workflow in (
|
||||
Workflow.objects.filter(
|
||||
enabled=True,
|
||||
triggers__type=trigger_type,
|
||||
)
|
||||
.prefetch_related("actions")
|
||||
.prefetch_related("actions__assign_view_users")
|
||||
.prefetch_related("actions__assign_view_groups")
|
||||
.prefetch_related("actions__assign_change_users")
|
||||
.prefetch_related("actions__assign_change_groups")
|
||||
.prefetch_related("actions__assign_custom_fields")
|
||||
.prefetch_related("actions__remove_tags")
|
||||
.prefetch_related("actions__remove_correspondents")
|
||||
.prefetch_related("actions__remove_document_types")
|
||||
.prefetch_related("actions__remove_storage_paths")
|
||||
.prefetch_related("actions__remove_custom_fields")
|
||||
.prefetch_related("actions__remove_owners")
|
||||
.prefetch_related("triggers")
|
||||
.order_by("order")
|
||||
elif overrides.custom_field_ids:
|
||||
for field in action.remove_custom_fields.filter(
|
||||
pk__in=overrides.custom_field_ids,
|
||||
):
|
||||
overrides.custom_field_ids.remove(field.pk)
|
||||
|
||||
use_overrides = overrides is not None
|
||||
messages = []
|
||||
|
||||
workflows = (
|
||||
Workflow.objects.filter(enabled=True, triggers__type=trigger_type)
|
||||
.prefetch_related(
|
||||
"actions",
|
||||
"actions__assign_view_users",
|
||||
"actions__assign_view_groups",
|
||||
"actions__assign_change_users",
|
||||
"actions__assign_change_groups",
|
||||
"actions__assign_custom_fields",
|
||||
"actions__remove_tags",
|
||||
"actions__remove_correspondents",
|
||||
"actions__remove_document_types",
|
||||
"actions__remove_storage_paths",
|
||||
"actions__remove_custom_fields",
|
||||
"actions__remove_owners",
|
||||
"triggers",
|
||||
)
|
||||
.order_by("order")
|
||||
)
|
||||
|
||||
for workflow in workflows:
|
||||
if not use_overrides:
|
||||
# This can be called from bulk_update_documents, which may be running multiple times
|
||||
# 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
|
||||
document.refresh_from_db()
|
||||
doc_tag_ids = list(document.tags.all().values_list("pk", flat=True))
|
||||
if matching.document_matches_workflow(
|
||||
document,
|
||||
workflow,
|
||||
trigger_type,
|
||||
):
|
||||
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
||||
|
||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
||||
action: WorkflowAction
|
||||
for action in workflow.actions.all():
|
||||
logger.info(
|
||||
f"Applying {action} from {workflow}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
message = f"Applying {action} from {workflow}"
|
||||
if not use_overrides:
|
||||
logger.info(message, extra={"group": logging_group})
|
||||
else:
|
||||
messages.append(message)
|
||||
|
||||
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
|
||||
assignment_action()
|
||||
|
||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||
removal_action()
|
||||
|
||||
if not use_overrides:
|
||||
# save first before setting tags
|
||||
document.save()
|
||||
document.tags.set(doc_tag_ids)
|
||||
|
||||
if use_overrides:
|
||||
return overrides, "\n".join(messages)
|
||||
|
||||
|
||||
@before_task_publish.connect
|
||||
def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
||||
|
@ -237,7 +237,6 @@ def get_custom_fields_context(
|
||||
)
|
||||
# String types need to be sanitized
|
||||
if field_instance.field.data_type in {
|
||||
CustomField.FieldDataType.DOCUMENTLINK,
|
||||
CustomField.FieldDataType.MONETARY,
|
||||
CustomField.FieldDataType.STRING,
|
||||
CustomField.FieldDataType.URL,
|
||||
|
46
src/documents/templating/title.py
Normal file
46
src/documents/templating/title.py
Normal file
@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_doc_title_w_placeholders(
|
||||
title: str,
|
||||
correspondent_name: str,
|
||||
doc_type_name: str,
|
||||
owner_username: str,
|
||||
local_added: datetime,
|
||||
original_filename: str,
|
||||
created: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Available title placeholders for Workflows depend on what has already been assigned,
|
||||
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
||||
for added / updated triggers
|
||||
"""
|
||||
formatting = {
|
||||
"correspondent": correspondent_name,
|
||||
"document_type": doc_type_name,
|
||||
"added": local_added.isoformat(),
|
||||
"added_year": local_added.strftime("%Y"),
|
||||
"added_year_short": local_added.strftime("%y"),
|
||||
"added_month": local_added.strftime("%m"),
|
||||
"added_month_name": local_added.strftime("%B"),
|
||||
"added_month_name_short": local_added.strftime("%b"),
|
||||
"added_day": local_added.strftime("%d"),
|
||||
"added_time": local_added.strftime("%H:%M"),
|
||||
"owner_username": owner_username,
|
||||
"original_filename": Path(original_filename).stem,
|
||||
}
|
||||
if created is not None:
|
||||
formatting.update(
|
||||
{
|
||||
"created": created.isoformat(),
|
||||
"created_year": created.strftime("%Y"),
|
||||
"created_year_short": created.strftime("%y"),
|
||||
"created_month": created.strftime("%m"),
|
||||
"created_month_name": created.strftime("%B"),
|
||||
"created_month_name_short": created.strftime("%b"),
|
||||
"created_day": created.strftime("%d"),
|
||||
"created_time": created.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
return title.format(**formatting).strip()
|
@ -306,6 +306,35 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
# only called once
|
||||
bulk_update_mock.assert_called_once_with([document.pk])
|
||||
|
||||
def test_test_storage_path(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to test a storage path
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- Correct response data
|
||||
"""
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
storage_path=self.sp1,
|
||||
title="Something",
|
||||
checksum="123",
|
||||
)
|
||||
response = self.client.post(
|
||||
f"{self.ENDPOINT}test/",
|
||||
json.dumps(
|
||||
{
|
||||
"document": document.id,
|
||||
"path": "path/{{ title }}",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, "path/Something")
|
||||
|
||||
|
||||
class TestBulkEditObjects(APITestCase):
|
||||
# See test_api_permissions.py for bulk tests on permissions
|
||||
|
@ -306,11 +306,11 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
|
||||
def test_workflow_match_multiple(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing workflow
|
||||
- Multiple existing workflows
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied with subsequent templates overwriting previous values
|
||||
- Workflow overrides are applied with subsequent workflows overwriting previous values
|
||||
or merging if multiple
|
||||
"""
|
||||
trigger1 = WorkflowTrigger.objects.create(
|
||||
@ -373,12 +373,12 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
|
||||
None,
|
||||
)
|
||||
document = Document.objects.first()
|
||||
# template 1
|
||||
# workflow 1
|
||||
self.assertEqual(document.document_type, self.dt)
|
||||
# template 2
|
||||
# workflow 2
|
||||
self.assertEqual(document.correspondent, self.c2)
|
||||
self.assertEqual(document.storage_path, self.sp)
|
||||
# template 1 & 2
|
||||
# workflow 1 & 2
|
||||
self.assertEqual(
|
||||
list(document.tags.all()),
|
||||
[self.t1, self.t2, self.t3],
|
||||
|
@ -140,6 +140,7 @@ from documents.serialisers import SavedViewSerializer
|
||||
from documents.serialisers import SearchResultSerializer
|
||||
from documents.serialisers import ShareLinkSerializer
|
||||
from documents.serialisers import StoragePathSerializer
|
||||
from documents.serialisers import StoragePathTestSerializer
|
||||
from documents.serialisers import TagSerializer
|
||||
from documents.serialisers import TagSerializerVersion1
|
||||
from documents.serialisers import TasksViewSerializer
|
||||
@ -151,6 +152,7 @@ from documents.serialisers import WorkflowTriggerSerializer
|
||||
from documents.signals import document_updated
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import empty_trash
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
from paperless.config import GeneralConfig
|
||||
@ -1550,6 +1552,25 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
return response
|
||||
|
||||
|
||||
class StoragePathTestView(GenericAPIView):
|
||||
"""
|
||||
Test storage path against a document
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = StoragePathTestSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
document = serializer.validated_data.get("document")
|
||||
path = serializer.validated_data.get("path")
|
||||
|
||||
result = validate_filepath_template_and_render(path, document)
|
||||
return Response(result)
|
||||
|
||||
|
||||
class UiSettingsView(GenericAPIView):
|
||||
queryset = UiSettings.objects.all()
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
@ -32,6 +32,7 @@ from documents.views import SelectionDataView
|
||||
from documents.views import SharedLinkView
|
||||
from documents.views import ShareLinkViewSet
|
||||
from documents.views import StatisticsView
|
||||
from documents.views import StoragePathTestView
|
||||
from documents.views import StoragePathViewSet
|
||||
from documents.views import SystemStatusView
|
||||
from documents.views import TagViewSet
|
||||
@ -166,6 +167,11 @@ urlpatterns = [
|
||||
TrashView.as_view(),
|
||||
name="trash",
|
||||
),
|
||||
re_path(
|
||||
"^storage_paths/test/",
|
||||
StoragePathTestView.as_view(),
|
||||
name="storage_paths_test",
|
||||
),
|
||||
re_path(
|
||||
r"^oauth/callback/",
|
||||
OauthCallbackView.as_view(),
|
||||
|
Loading…
x
Reference in New Issue
Block a user